Compare commits
75
Commits
@@ -1,4 +1,6 @@
|
||||
build/
|
||||
Builds/
|
||||
build-*/
|
||||
build-release/
|
||||
build-sim/
|
||||
build-watch/
|
||||
|
||||
+130
-28
@@ -1,6 +1,6 @@
|
||||
# FuelBoard App Store Submission Plan
|
||||
|
||||
Updated: 2026-08-16 · Target: safe App Store submission.
|
||||
Updated: 2026-09-26 · Target: safe App Store submission.
|
||||
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
||||
checklist: what must happen, who owns it, and the timeline.
|
||||
|
||||
@@ -13,10 +13,108 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
on Apple's network see real data.
|
||||
- ~~Always-location risk~~ → **Always is requested only when the user enables
|
||||
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
||||
Onboarding requests WhenInUse only.
|
||||
Onboarding requests WhenInUse only. FuelBoard does **not** declare the
|
||||
persistent real-time background location mode; optional alerts use
|
||||
CoreLocation region monitoring/significant-change behaviour instead.
|
||||
|
||||
## StoreKit sandbox: verified working configuration and caveats
|
||||
|
||||
### Verified configuration
|
||||
|
||||
The FuelBoard consumable tip products are:
|
||||
|
||||
- `com.apt.fuelboard.tip099`
|
||||
- `com.apt.fuelboard.tip299`
|
||||
- `com.apt.fuelboard.tip499`
|
||||
|
||||
The app requests these exact **Product IDs** with StoreKit 2
|
||||
`Product.products(for:)`; the UI names are display labels and are not used for
|
||||
lookup. The app bundle ID, App Store Connect app, explicit App ID, development
|
||||
team, and Sandbox Apple Account must belong to the same developer account.
|
||||
|
||||
Two test environments must remain distinct:
|
||||
|
||||
- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
|
||||
in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
|
||||
App Store Connect and confirms the app's StoreKit loading and purchase code.
|
||||
- **Apple sandbox testing:** select **None** for StoreKit Configuration, then
|
||||
use Product > Run on a physical device with the development-signed build and
|
||||
a Sandbox Apple Account signed in under Settings > Developer. This exercises
|
||||
the real App Store Connect catalog.
|
||||
|
||||
The local configuration returning all three products does not prove that the
|
||||
Apple sandbox catalog is ready. Conversely, a successful local run showing `$`
|
||||
does not mean the App Store Connect price is USD: `Product.displayPrice` is
|
||||
formatted for the active StoreKit/device storefront. Local StoreKit prices are
|
||||
placeholders and do not upload to App Store Connect.
|
||||
|
||||
### Availability versus pricing
|
||||
|
||||
App Store Connect has separate controls:
|
||||
|
||||
- **Availability** controls the storefronts where the IAP is offered. The
|
||||
verified FuelBoard configuration uses **United Kingdom only**.
|
||||
- **Price schedule** may show generated prices for **175 countries and
|
||||
regions**. This is expected and does not make the IAP available in all 175
|
||||
storefronts; it is Apple's price conversion schedule.
|
||||
|
||||
The Sandbox Apple Account's App Store Country or Region must match an enabled
|
||||
IAP storefront. After changing the sandbox tester's storefront, sign out and
|
||||
back in under Settings > Developer so the storefront change activates.
|
||||
|
||||
### Important propagation/cache caveat
|
||||
|
||||
During sandbox diagnosis, `Product.products(for:)` completed successfully but
|
||||
returned zero products with the IAP availability set to UK. Temporarily
|
||||
changing one product's availability to all regions caused it to appear in the
|
||||
sandbox catalog; changing it back to UK left it working. This indicates an
|
||||
Apple-side catalog refresh or propagation/cache issue, not a change required in
|
||||
the StoreKit code. Apple documents that product metadata changes can take up to
|
||||
approximately one hour to appear in sandbox.
|
||||
|
||||
If an otherwise valid product returns zero products:
|
||||
|
||||
1. Confirm the Sandbox Apple Account belongs to the same developer team.
|
||||
2. Confirm its storefront is an enabled availability region.
|
||||
3. Sign out and back in to the sandbox account on the device.
|
||||
4. Delete/reinstall the development build and retry with StoreKit Configuration
|
||||
set to **None**.
|
||||
5. If necessary, temporarily broaden one product's availability to all
|
||||
regions, allow propagation, verify the product, then restore the intended
|
||||
availability and allow propagation again.
|
||||
|
||||
Record whether the diagnostic result is **request failed**, **completed with
|
||||
zero products**, or **a partial product set**. These are different failure
|
||||
classes. Do not add fallback prices or change product lookup to display names.
|
||||
|
||||
Sandbox testing does not require prior App Review approval of the IAP. It does
|
||||
require active developer membership, an active Paid Applications Agreement,
|
||||
complete banking and tax information, valid product metadata and pricing, the
|
||||
correct app/team association, and a matching Sandbox Apple Account.
|
||||
|
||||
## Hard blockers (must happen before ANY submission)
|
||||
|
||||
0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP
|
||||
configuration and sandbox testing. App Store Connect →
|
||||
Business > Paid Applications Agreement → View and Agree. **This is the most
|
||||
common cause of a 2.1(b) "unable to purchase item / error message" rejection
|
||||
— without it every sandbox purchase fails.** (REJECTED once for this:
|
||||
review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.)
|
||||
**Status 2026-09-25: agreement clicked Agree but shows "Pending user info" —
|
||||
this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN)
|
||||
and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business >
|
||||
Payments & Tax until the agreement shows "Active" and every tab is green.
|
||||
Only then do sandbox IAPs start working.
|
||||
0. **[USER] Tip IAPs configured and available to the sandbox** — the three
|
||||
consumables may exist but must ALSO have complete metadata, a current price,
|
||||
and an enabled storefront availability. Prior App Review approval or
|
||||
`Cleared for Sale` status is **not required for sandbox product discovery**.
|
||||
They must be backed by the **In-App Purchase capability on the App ID**
|
||||
`com.apt.fuelboard` (developer portal → Identifiers → App ID capability).
|
||||
3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn
|
||||
SettingsView.swift. Test product discovery and purchase with a Sandbox
|
||||
Apple Account first: Settings → Support FuelBoard.
|
||||
|
||||
1. **[USER] Apple Developer Program membership** — the app has never been
|
||||
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
||||
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
|
||||
@@ -25,31 +123,32 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
with the **App Group `group.com.apt.fuelboard`** capability AND the
|
||||
**In-App Purchase** capability enabled (the widget + app-group cache
|
||||
depend on the group; the tip jar needs IAP).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
|
||||
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
|
||||
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
|
||||
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
||||
provisioning profile (or Xcode automatic signing once the account is in
|
||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||
collects location. A one-page GitHub Pages privacy policy is fine.
|
||||
collects location. Policy text is now in `PRIVACY.md`; publish it via the
|
||||
FuelBoard marketing site or GitHub Pages and paste that public URL into App
|
||||
Store Connect.
|
||||
|
||||
## Required tasks — owner [ME] (technical, do after account exists)
|
||||
|
||||
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
|
||||
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
|
||||
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
|
||||
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
|
||||
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
|
||||
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
|
||||
- [ ] Distribution archive + TestFlight upload (needs Apple account)
|
||||
- [x] **Version bump** → `1.0.1` (build 2), app + widget plists
|
||||
- [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets
|
||||
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
|
||||
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
||||
up to twice a day." (2.3.1 hygiene)
|
||||
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
||||
explainer + "Browse all UK stations" button (switches to Closest)
|
||||
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
|
||||
purpose**: the user's home-testing installs the Release IPA and toggles
|
||||
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
|
||||
About → Version ×5 — invisible to reviewers; no credentials involved).
|
||||
Gating it out of Release would break that workflow for zero review gain.
|
||||
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
|
||||
permission string, relay toggle, or diagnostic network beacon remains in
|
||||
the app/widget/watch binary. Hidden Debug still exists for local notification
|
||||
and widget diagnostics only.
|
||||
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
||||
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
||||
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
|
||||
@@ -75,8 +174,7 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
category (Utilities), pricing (Free), availability (UK first?)
|
||||
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
||||
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
||||
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
|
||||
linked, not used for tracking; no ATT prompt needed)
|
||||
for alerts/nearby; no tracking; no ATT prompt needed)
|
||||
- [ ] Age rating questionnaire (4+; no objectionable content)
|
||||
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
||||
exemption (5A992a), no documentation needed
|
||||
@@ -90,19 +188,23 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
> Prices are downloaded from the internet up to twice a day; the app filters
|
||||
> 5/10/15-mile radii on-device and works offline from a bundled snapshot.
|
||||
> Location is used to (a) rank stations by distance and (b) power the
|
||||
> optional Alerts feature: when the user enables alerts, the app geofences
|
||||
> the cheapest station in their radius and sends a notification when they
|
||||
> approach it. Always-location is requested only when the user enables
|
||||
> alerts. The app also provides a home-screen widget showing the current
|
||||
> cheapest station nearby, and a price-history Trends chart for starred
|
||||
> stations. All data is UK-wide (Fuel Finder covers England, Scotland, Wales
|
||||
> and Northern Ireland); users outside the UK see an empty state explaining
|
||||
> this.
|
||||
> optional Alerts feature: when the user enables alerts, the app registers
|
||||
> CoreLocation regions around the cheapest station in their radius and sends
|
||||
> a notification when they approach it. FuelBoard does not use persistent
|
||||
> real-time background location tracking; it relies on iOS region monitoring
|
||||
> and significant-change behaviour for optional alerts. Always-location is
|
||||
> requested only when the user enables alerts. The app also provides a
|
||||
> home-screen widget showing the current cheapest station nearby, and a
|
||||
> price-history Trends chart for starred stations. All data is UK-wide (Fuel
|
||||
> Finder covers England, Scotland, Wales and Northern Ireland); users outside
|
||||
> the UK see an empty state explaining this.
|
||||
|
||||
## Review risks (know before submitting)
|
||||
|
||||
- **Always-location (5.1.1)** — the #1 question. The feature is visible
|
||||
(Alerts tab) and opt-in; the review notes above say so explicitly.
|
||||
- **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
|
||||
and opt-in; the app does not declare persistent background location mode,
|
||||
and the review notes describe region monitoring/significant-change rather
|
||||
than continuous tracking.
|
||||
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
|
||||
task above + review notes.
|
||||
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
|
||||
@@ -110,8 +212,8 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
glance. It is declared in the plist already.
|
||||
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
||||
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
||||
- **GitHub raw as data source** — public data, no credentials; bundled dump
|
||||
covers any regional GitHub unreachability.
|
||||
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
|
||||
Network permission; bundled dump covers any regional GitHub unreachability.
|
||||
|
||||
## Timeline (best case)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 686 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
+7
-10
@@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app.
|
||||
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
|
||||
full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
|
||||
filters/sorts on-device, with a bundled snapshot as the no-network last resort.
|
||||
LAN relay is dev-only (hidden `fuelboard.relayFallback` flag).
|
||||
LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source.
|
||||
|
||||
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
||||
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
@@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
||||
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
||||
unreachable from any consumer phone and attempting it would fire the Local
|
||||
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
||||
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
||||
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
||||
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
||||
ping fires only with the dev flag on. **Local Network permission removed from
|
||||
onboarding entirely** — data page is informational, replay on cellular can no
|
||||
longer hang. 95 tests.
|
||||
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
|
||||
fallback, private IP, Local Network permission, or diagnostic network beacon
|
||||
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
|
||||
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
|
||||
Local Network permission removed entirely — data page is informational, replay
|
||||
on cellular can no longer hang. 107+ tests.
|
||||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||||
live in relay `.env` only, never in the IPA
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -33,8 +33,6 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
@@ -48,7 +46,6 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<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, ); }; };
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
|
||||
FBA000000000000000000002 /* FuelHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
|
||||
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
|
||||
FBA000000000000000000004 /* FuelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
|
||||
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
|
||||
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
|
||||
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000107 /* WidgetLocationFetcher.swift */; };
|
||||
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000108 /* FuelBoardTests.swift */; };
|
||||
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000109 /* FuelHistoryTests.swift */; };
|
||||
FBA00000000000000000000A /* LiveChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010A /* LiveChainTests.swift */; };
|
||||
FBA00000000000000000000B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010B /* XCTest.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -37,17 +48,6 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -59,6 +59,17 @@
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -73,10 +84,23 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
|
||||
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FBA000000000000000000101 /* DataRefreshMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRefreshMode.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000102 /* FuelHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistory.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000103 /* FuelPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelPriceProvider.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000104 /* FuelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelStore.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000105 /* MirrorFuelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorFuelProvider.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000106 /* SiriCheapestLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriCheapestLookup.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000107 /* WidgetLocationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationFetcher.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000108 /* FuelBoardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelBoardTests.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000109 /* FuelHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistoryTests.swift; sourceTree = "<group>"; };
|
||||
FBA00000000000000000010A /* LiveChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChainTests.swift; sourceTree = "<group>"; };
|
||||
FBA00000000000000000010B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -136,18 +160,37 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000302 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA00000000000000000010B /* XCTest.framework */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB000000000000000000000D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000200 /* FuelBoardTests */,
|
||||
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||
BB000000000000000000000E /* Products */,
|
||||
AD64FC4B30739A4B008F27A9 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -158,16 +201,52 @@
|
||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000200 /* FuelBoardTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000201 /* Sources */,
|
||||
FBA000000000000000000202 /* Tests */,
|
||||
);
|
||||
path = FuelBoardTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000201 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000101 /* DataRefreshMode.swift */,
|
||||
FBA000000000000000000102 /* FuelHistory.swift */,
|
||||
FBA000000000000000000103 /* FuelPriceProvider.swift */,
|
||||
FBA000000000000000000104 /* FuelStore.swift */,
|
||||
FBA000000000000000000105 /* MirrorFuelProvider.swift */,
|
||||
FBA000000000000000000106 /* SiriCheapestLookup.swift */,
|
||||
FBA000000000000000000107 /* WidgetLocationFetcher.swift */,
|
||||
);
|
||||
name = Sources;
|
||||
path = Sources/FuelBoardShared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000202 /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000108 /* FuelBoardTests.swift */,
|
||||
FBA000000000000000000109 /* FuelHistoryTests.swift */,
|
||||
FBA00000000000000000010A /* LiveChainTests.swift */,
|
||||
);
|
||||
name = Tests;
|
||||
path = Tests/FuelBoardSharedTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||
buildPhases = (
|
||||
ADCE5B4B3033A329008C131C /* Sources */,
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||
@@ -260,6 +339,23 @@
|
||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
|
||||
buildPhases = (
|
||||
FBA000000000000000000301 /* Sources */,
|
||||
FBA000000000000000000302 /* Frameworks */,
|
||||
FBA000000000000000000303 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = FuelBoardSharedTests;
|
||||
productName = FuelBoardSharedTests;
|
||||
productReference = AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -278,6 +374,11 @@
|
||||
};
|
||||
BB000000000000000000000F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
SystemCapabilities = {
|
||||
com.apple.InAppPurchase = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -299,6 +400,7 @@
|
||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||
FBA000000000000000000300 /* FuelBoardSharedTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -332,6 +434,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000303 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -356,6 +465,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000301 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */,
|
||||
FBA000000000000000000002 /* FuelHistory.swift in Sources */,
|
||||
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */,
|
||||
FBA000000000000000000004 /* FuelStore.swift in Sources */,
|
||||
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */,
|
||||
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */,
|
||||
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */,
|
||||
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
|
||||
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
|
||||
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -405,8 +531,9 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -418,14 +545,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -471,8 +597,9 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -484,14 +611,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -537,7 +663,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -549,13 +675,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -602,7 +728,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -614,13 +740,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -698,8 +824,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -707,9 +834,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
@@ -721,8 +849,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -730,9 +859,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
@@ -745,7 +875,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -754,7 +884,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -770,7 +900,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -779,7 +909,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -789,10 +919,56 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
FBA000000000000000000401 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
FBA000000000000000000402 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ADCE5B573033A32B008C131C /* Debug */,
|
||||
@@ -837,6 +1013,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FBA000000000000000000401 /* Debug */,
|
||||
FBA000000000000000000402 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = BB0000000000000000000004 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.8">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FBA000000000000000000300"
|
||||
BuildableName = "FuelBoardSharedTests.xctest"
|
||||
BlueprintName = "FuelBoardSharedTests"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../../FuelBoardTips.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
language = "en"
|
||||
region = "GB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersion = "1.0"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersion = "1.0"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
+59
-31
@@ -12,6 +12,12 @@ struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||
@Binding var alertMode: AlertMode
|
||||
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
|
||||
/// the fill size and MPG that decide the saving live. Read back so the
|
||||
/// alert's own rule is visible without leaving the tab.
|
||||
let bestValueThresholdGBP: Double
|
||||
let bestValueConfigured: Bool
|
||||
@Binding var favouriteDropEnabled: Bool
|
||||
@Binding var favouriteDropFuel: FuelType
|
||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||
@@ -38,7 +44,7 @@ struct AlertsView: View {
|
||||
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
||||
private var searchInUnitLabel: String {
|
||||
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
|
||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
|
||||
@@ -46,7 +52,7 @@ struct AlertsView: View {
|
||||
/// what the option really means.
|
||||
private func distanceLabel(forMiles miles: Int) -> String {
|
||||
let shown = distanceUnit.displayMiles(miles)
|
||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// Situational guidance for the currently-selected alert radius, shown
|
||||
@@ -57,23 +63,23 @@ struct AlertsView: View {
|
||||
private var alertRadiusGuidance: String {
|
||||
switch alertChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
|
||||
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
|
||||
case .fixed(let value):
|
||||
let optionLabel = "\(value) \(distanceUnit.label(for: Double(value)))"
|
||||
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
let body: String
|
||||
switch value {
|
||||
case 1:
|
||||
body = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
|
||||
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
|
||||
case 2:
|
||||
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
|
||||
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
|
||||
case 3:
|
||||
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
|
||||
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
|
||||
case 5:
|
||||
body = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle."
|
||||
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
|
||||
default:
|
||||
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
|
||||
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
|
||||
}
|
||||
return "\(optionLabel) — \(body)"
|
||||
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,74 +140,96 @@ struct AlertsView: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
|
||||
Picker("Fuel", selection: $favouriteDropFuel) {
|
||||
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Button("Check now", action: onCheckFavouriteDropNow)
|
||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||
.disabled(!favouriteDropEnabled)
|
||||
} header: {
|
||||
Text("Price-drop alerts")
|
||||
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
||||
} footer: {
|
||||
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
|
||||
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Cheapest station", isOn: $enabled)
|
||||
Picker("Fuel", selection: $fuel) {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
if !bestValueConfigured {
|
||||
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker("Radius", selection: alertChoice) {
|
||||
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
|
||||
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
|
||||
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||
}
|
||||
}
|
||||
Text(alertRadiusGuidance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if alertMode == .bestValue {
|
||||
// The threshold is edited in Settings → Fuel Savings: it
|
||||
// labels station rows BEST VALUE as well as gating this
|
||||
// alert, so it lives with the fill size and MPG that
|
||||
// produce the saving. Read back here, not offered twice.
|
||||
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if enabled && monitoredCount == 0 {
|
||||
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
||||
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Proximity alerts")
|
||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||
} footer: {
|
||||
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
|
||||
let modeDescription = alertMode == .bestValue
|
||||
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
|
||||
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
|
||||
Text(modeDescription)
|
||||
}
|
||||
|
||||
if enabled, let lastAlert {
|
||||
Section("Last alert") {
|
||||
Section(String(localized: "Last alert", comment: "Last alert section title")) {
|
||||
Label(lastAlert, systemImage: "bell.badge.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Cheapest station", isOn: $liveActivityEnabled)
|
||||
Picker("Fuel", selection: $liveActivityFuel) {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker("Distance", selection: laChoice) {
|
||||
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
|
||||
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Live Activity")
|
||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||
} footer: {
|
||||
Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
|
||||
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Alerts")
|
||||
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconArtwork.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconPreview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconPreviewDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
||||
"filename" : "PreviewWallpaper.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_applegreen_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_asda_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_bp_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_circle_k_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_essar_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_esso_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_gleaner_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_gulf_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_highland_fuels_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_jet_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_maxol_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_morrisons_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_murco_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_sainsburys_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_shell_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_spar_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_tesco_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_texaco_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_the_co_operative_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_valero_2x.png",
|
||||
"idiom" : "universal",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_welcome_break_2x.png",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
+1204
-99
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,16 @@ struct FavouritesView: View {
|
||||
let distanceUnit: DistanceUnit
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
let stationTapAction: StationTapAction
|
||||
/// Fuel Savings config. When it is configured this tab shows the SAME
|
||||
/// fill-up control and per-row estimate as the Stations list: one session
|
||||
/// amount is shared, so adjusting it in either list updates both, and it
|
||||
/// still never touches the Settings default.
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
/// Estimated saving for a favourite, against the closest station by
|
||||
/// journey distance — the same baseline and definition the Stations list uses.
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||
@@ -70,6 +80,10 @@ struct FavouritesView: View {
|
||||
distanceUnit: DistanceUnit,
|
||||
priceDisplayStyle: PriceDisplayStyle,
|
||||
stationTapAction: StationTapAction,
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
onHistoryUnavailable: (() -> Void)? = nil,
|
||||
@@ -80,6 +94,10 @@ struct FavouritesView: View {
|
||||
self.distanceUnit = distanceUnit
|
||||
self.priceDisplayStyle = priceDisplayStyle
|
||||
self.stationTapAction = stationTapAction
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
_fuelSavingsAmount = fuelSavingsAmount
|
||||
self.savingsForStation = savingsForStation
|
||||
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
self.onHistoryUnavailable = onHistoryUnavailable
|
||||
@@ -106,9 +124,9 @@ struct FavouritesView: View {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No favourites yet")
|
||||
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
|
||||
.font(.headline)
|
||||
Text("Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.")
|
||||
Text(String(localized: "Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.", comment: "Favourites empty-state explanation"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -117,13 +135,21 @@ struct FavouritesView: View {
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
} else {
|
||||
Section("Fuel type") {
|
||||
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
|
||||
FuelTypeSegmentedPicker(
|
||||
selection: $fuel,
|
||||
fuels: availableFuels
|
||||
)
|
||||
}
|
||||
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
Section {
|
||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||
} footer: {
|
||||
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||
StationRow(
|
||||
@@ -133,30 +159,31 @@ struct FavouritesView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: cheapestPrice,
|
||||
isTopResult: index == 0,
|
||||
isTopResult: station.id == cheapest?.id,
|
||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
}
|
||||
.onMove(perform: moveFavourite)
|
||||
} header: {
|
||||
Text("\(activeFuel.displayName) favourites")
|
||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||
} footer: {
|
||||
Text("The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.")
|
||||
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
||||
}
|
||||
|
||||
if let cheapest = cheapest {
|
||||
Section {
|
||||
Text("\(cheapest.name) is your cheapest \(activeFuel.displayName) favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.")
|
||||
Text(String(format: String(localized: "%@ is your cheapest %@ favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.", comment: "Cheapest favourite explanation"), cheapest.name, activeFuel.displayName))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Favourites")
|
||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||
.toolbar {
|
||||
if !favourites.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
@@ -164,7 +191,7 @@ struct FavouritesView: View {
|
||||
showTrends = true
|
||||
} label: {
|
||||
Image(systemName: "chart.xyaxis.line")
|
||||
.accessibilityLabel("Trends")
|
||||
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
|
||||
}
|
||||
EditButton()
|
||||
}
|
||||
@@ -183,9 +210,24 @@ struct FavouritesView: View {
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit
|
||||
distanceUnit: distanceUnit,
|
||||
priceStyle: priceDisplayStyle,
|
||||
selectedFuel: activeFuel,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// Every station listed here IS a favourite for the tab's
|
||||
// fuel, so the star starts filled; tapping it unstars.
|
||||
isFavourite: true,
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||
// "Setup Best Value" from the explanation sheet, opened inside
|
||||
// this view's More info sheet. That sheet closes itself; this one
|
||||
// is presented from HERE and would otherwise stay over Settings.
|
||||
detailStation = nil
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
// QA hook: launch with `-showTrends` to open the sheet
|
||||
|
||||
@@ -21,7 +21,7 @@ struct FuelBoardApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
rootView
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
||||
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
|
||||
private func startQALiveActivity(fuel: FuelType) {
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
@@ -42,6 +60,10 @@ struct FuelBoardApp: App {
|
||||
pricePence: 1499,
|
||||
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: 8.0,
|
||||
// QA mock: marked approximate so the hook renders the LONGEST
|
||||
// distance form ("Approx. 8.0 mi") and the island / Lock Screen
|
||||
// layout can be checked against it.
|
||||
distanceIsApproximate: true,
|
||||
lat: 51.5,
|
||||
lng: -0.12,
|
||||
updatedAt: Date()
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FuelSavingsAdjustmentView: View {
|
||||
@Binding var amount: FuelSavingsFillAmount
|
||||
|
||||
@State private var unitChangeNotice: String?
|
||||
|
||||
private var range: ClosedRange<Double> {
|
||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||
}
|
||||
|
||||
private var step: Double {
|
||||
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
|
||||
}
|
||||
|
||||
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
|
||||
/// a typed figure could sit outside the slider's own bounds, leaving the
|
||||
/// control and the value it claimed to show disagreeing. Slider-only means
|
||||
/// what you see is the figure the estimate actually uses.
|
||||
private var valueText: String {
|
||||
switch amount.unit {
|
||||
case .pounds: return String(format: "£%.0f", amount.value)
|
||||
case .litres: return String(format: "%.0f L", amount.value)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("How much are you filling up?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Fill-up unit", selection: unitBinding) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
HStack {
|
||||
Slider(value: sliderBinding, in: range, step: step)
|
||||
.accessibilityLabel("Fill-up amount")
|
||||
.accessibilityValue(valueText)
|
||||
Text(valueText)
|
||||
.monospacedDigit()
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
}
|
||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if let unitChangeNotice {
|
||||
Text(unitChangeNotice)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
||||
Binding(
|
||||
get: { amount.unit },
|
||||
set: { newUnit in
|
||||
guard newUnit != amount.unit else { return }
|
||||
// No one litre figure equals a budget at every candidate price.
|
||||
// A unit change starts a new, explicitly labelled comparison.
|
||||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var sliderBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
|
||||
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour-coded verdict capsule shared by the station listing rows, the
|
||||
/// detail sheet and the compact summary.
|
||||
///
|
||||
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
||||
/// a Label lays its icon and title out across the row and leaves a visible gap
|
||||
/// between them (22pt in the listing row), which read as the text having
|
||||
/// drifted away from its symbol. The icon is decorative here — the verdict is
|
||||
/// in the text — so VoiceOver reads the capsule as one phrase.
|
||||
struct FuelSavingsCapsule: View {
|
||||
let result: FuelSavingsResult
|
||||
var compact: Bool = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: compact ? 3 : 5) {
|
||||
Image(systemName: Self.symbol(for: result.status))
|
||||
.accessibilityHidden(true)
|
||||
Text(result.capsuleHeadline())
|
||||
.lineLimit(1)
|
||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||||
// which the icon, its spacing and the 8pt padding either side
|
||||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
||||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
||||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
||||
// which clears it at ~10pt type: scaled, never truncated.
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||
.foregroundStyle(Self.color(for: result.status))
|
||||
.padding(.horizontal, compact ? 8 : 12)
|
||||
.padding(.vertical, compact ? 4 : 7)
|
||||
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
|
||||
}
|
||||
|
||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
||||
/// baseline — the same green/amber/red family the price dot already uses.
|
||||
static func color(for status: FuelSavingsStatus) -> Color {
|
||||
switch status {
|
||||
case .worthTheDrive: return .green
|
||||
case .notWorthTheDrive: return .red
|
||||
case .breakEven: return .secondary
|
||||
case .baseline: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||
switch status {
|
||||
case .worthTheDrive: return "checkmark.circle.fill"
|
||||
case .breakEven: return "equal.circle.fill"
|
||||
case .notWorthTheDrive: return "xmark.circle.fill"
|
||||
case .baseline: return "location.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "How Best Value works" explanation as its own sheet.
|
||||
///
|
||||
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
|
||||
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||
/// unchanged (the same `Localizable.strings` keys are reused — the old section
|
||||
/// header string is the sheet's title).
|
||||
struct HowBestValueSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
||||
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
||||
// The whole point of the sheet is to explain the feature,
|
||||
// so the way to switch it on lives here rather than only
|
||||
// in Settings. Full-width: this is the card's one action.
|
||||
Button {
|
||||
openBestValueSettings()
|
||||
} label: {
|
||||
Text("Setup Best Value")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
// `.large` is the standard iOS button height (~50pt): the
|
||||
// default `.borderedProminent` is considerably shorter and
|
||||
// read as a compact control rather than a button.
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("The working, step by step") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
step("1. The baseline",
|
||||
"The closest station by journey distance. It is the trip you would make anyway, so everything else is measured against it.")
|
||||
step("2. The return trip",
|
||||
"For each station we work out the journey there and back. Where the roads have been measured we use the real driving route; until then we double the straight-line distance and mark the figure “Approx.”.")
|
||||
step("3. Extra return travel",
|
||||
"This station's return trip minus the baseline's. It is the extra you add on top of the journey you were already making — not the whole trip. Because both figures are return trips, it can read larger than the one-way distance shown above the map.")
|
||||
step("4. Extra driving cost",
|
||||
"What that extra distance costs in fuel: the extra miles, divided by your MPG, priced at the station you are driving to.")
|
||||
step("5. Fuel-price saving",
|
||||
"What the difference in price per litre is worth on your fill-up.")
|
||||
step("6. Total",
|
||||
"The fuel-price saving minus the extra driving cost. If the drive costs more than the price saves, the total is shown as an extra cost instead, and the station is not Best Value.")
|
||||
step("7. Best Value",
|
||||
"The total clears the minimum saving you set in Settings → Fuel Savings.")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
|
||||
Text("With Litres, every station is compared for the fixed quantity you selected.")
|
||||
Text("Results are marked “Approx.” until road distances are available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Best Value works")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.fraction(0.72)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
#if DEBUG
|
||||
// QA hook: `-qaSetupBestValue` runs the Setup Best Value action without a
|
||||
// tap (simctl cannot tap), so the tab switch can be screenshotted.
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.arguments.contains("-qaSetupBestValue") else { return }
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
openBestValueSettings()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Closes this sheet and asks the app to land on Settings → Best Value, whose
|
||||
/// section is the first thing in its list and holds the fill-up amount, MPG
|
||||
/// and minimum saving. This sheet sits two levels below the tab view — More
|
||||
/// info sheet, or the Key sheet — so it cannot switch tabs itself: it closes
|
||||
/// itself and posts, and ContentView does the rest.
|
||||
private func openBestValueSettings() {
|
||||
dismiss()
|
||||
// Announce only once this sheet has animated away. The host then closes
|
||||
// the sheet this one was presented from — the More info sheet, or the Key
|
||||
// sheet — and switches to Settings. Asking for that while the child was
|
||||
// still dismissing could drop one of the two dismissals, which is what
|
||||
// left the Settings tab covered by a sheet.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
NotificationCenter.default.post(name: .fuelBoardOpenBestValueSettings, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// One numbered step: a lead-in, then the plain explanation. Same words
|
||||
/// either way — this only sets the hierarchy.
|
||||
private func step(_ title: String, _ explanation: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(explanation)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
|
||||
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
|
||||
/// row-wide tap target that could swallow a neighbouring control (see the
|
||||
/// two-buttons-in-one-row rule).
|
||||
struct HowBestValueRow: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
|
||||
/// be screenshotted (simctl has no tap command) — the same pattern as
|
||||
/// `-showKeySheet` / `-showDetail`.
|
||||
enum BestValueInfoQA {
|
||||
static var isRequested: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
extension View {
|
||||
/// Puts a Done button above the keyboard.
|
||||
///
|
||||
/// The numeric fields (Settings → Average fuel economy and Typical
|
||||
/// fill-up, and the Worth the Drive fill-up box in the station list) use
|
||||
/// `.decimalPad`, which has NO Return key. Without this there is no way to
|
||||
/// dismiss the keyboard once such a field is tapped, and a
|
||||
/// `TextField(value:format:)` only commits the typed value when editing
|
||||
/// ends — so the number never stuck either.
|
||||
///
|
||||
/// WHY IT IS DRAWN HERE RATHER THAN AS `.toolbar(placement: .keyboard)`:
|
||||
/// the keyboard-toolbar form is device-dependent. The same build showed the
|
||||
/// Done button on the iPhone 15 Pro and NOT on the iPhone SE (both iOS
|
||||
/// 27.0.1, same install, same binary), leaving the SE with no way to
|
||||
/// dismiss or commit a numeric field. Drawing the bar in the screen's own
|
||||
/// bottom safe area renders identically on every device, so the accessibility
|
||||
/// of the numeric fields no longer depends on the accessory view appearing.
|
||||
///
|
||||
/// Apply ONCE per screen, to the list or form containing the fields: any
|
||||
/// keyboard on that screen shows the bar, so attaching it to each field
|
||||
/// would stack several Done buttons.
|
||||
///
|
||||
/// - Parameter action: clears the screen's focus state. Prefer this where a
|
||||
/// `@FocusState` exists; the default resigns the current first responder,
|
||||
/// which works for fields inside subviews that have no focus binding (the
|
||||
/// fill-up box in the station list is one).
|
||||
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
|
||||
modifier(KeyboardDoneBar(action: action))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a Done bar while a keyboard is up on this screen.
|
||||
///
|
||||
/// Visibility comes from the keyboard notifications rather than a `@FocusState`
|
||||
/// so the bar also works for fields inside subviews that have no focus binding.
|
||||
private struct KeyboardDoneBar: ViewModifier {
|
||||
let action: (() -> Void)?
|
||||
|
||||
@State private var isKeyboardVisible = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if isKeyboardVisible {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
if let action {
|
||||
action()
|
||||
} else {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
}
|
||||
isKeyboardVisible = false
|
||||
}
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
||||
isKeyboardVisible = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
||||
isKeyboardVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ enum LiveActivityManager {
|
||||
return
|
||||
}
|
||||
|
||||
// ONE cache read for the figure AND its confidence: the activity shows
|
||||
// "Approx." exactly when the app's own rows would.
|
||||
let distance = FuelStore.stationDistanceDisplay(
|
||||
station: best, userLat: location.lat, userLng: location.lng
|
||||
)
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
fuel: fuel,
|
||||
stationID: best.id,
|
||||
@@ -57,7 +62,8 @@ enum LiveActivityManager {
|
||||
brand: best.brand,
|
||||
pricePence: price,
|
||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||
distanceKM: distance.km,
|
||||
distanceIsApproximate: distance.isApproximate,
|
||||
lat: best.lat,
|
||||
lng: best.lng,
|
||||
updatedAt: Date()
|
||||
@@ -73,7 +79,7 @@ enum LiveActivityManager {
|
||||
currentStartDate = nil
|
||||
start(attributes: attributes, state: state)
|
||||
} else {
|
||||
Task { await current.update(using: state) }
|
||||
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
}
|
||||
} else {
|
||||
start(attributes: attributes, state: state)
|
||||
@@ -119,14 +125,20 @@ enum LiveActivityManager {
|
||||
lat: Double,
|
||||
lng: Double
|
||||
) -> FuelStation? {
|
||||
// Radius and tie-break both use the distance the pill SHOWS, so the
|
||||
// activity can never name a station the app's own list has filtered out.
|
||||
stations
|
||||
.filter { $0.prices[fuel] != nil }
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
||||
.filter {
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: lat, userLng: lng)
|
||||
}
|
||||
.min { lhs, rhs in
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
|
||||
return FuelStore.displayDistanceKM(station: lhs, userLat: lat, userLng: lng)
|
||||
< FuelStore.displayDistanceKM(station: rhs, userLat: lat, userLng: lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-55
@@ -3,19 +3,31 @@ import CoreLocation
|
||||
import UserNotifications
|
||||
import Network
|
||||
|
||||
/// First-launch onboarding: introduces FuelBoard, then walks the user through
|
||||
/// the three system permissions (location, notifications, local network for
|
||||
/// data loading) with in-context prompts. In production it appears once at
|
||||
/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
|
||||
/// test button in the Alerts tab re-opens it anytime.
|
||||
/// First-launch onboarding: introduces FuelBoard, walks the user through the
|
||||
/// two system permissions (location and notifications) with in-context prompts,
|
||||
/// then offers Best Value — explained, switched on or declined, and configured
|
||||
/// with the same sliders Settings uses. Price data downloads over HTTPS with no
|
||||
/// Local Network permission. In production it appears once at initial launch
|
||||
/// (driven by `FuelStore.loadHasCompletedOnboarding()`); the Settings tab has a
|
||||
/// row that re-opens it anytime.
|
||||
///
|
||||
/// The Best Value page itself lives in `BestValueIntroContent`, because the same
|
||||
/// page is also shown to EXISTING users (once per announcement version, by
|
||||
/// `BestValueIntroView`) — one source for its copy, controls and rules, so the
|
||||
/// two can never drift apart.
|
||||
struct OnboardingView: View {
|
||||
var onFinish: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var prompter = OnboardingPermissionPrompter()
|
||||
@State private var page = 0
|
||||
@State private var page = OnboardingView.initialPage()
|
||||
/// Best Value's own copy of the stored settings. Loaded so a replay starts
|
||||
/// from what the user already has; the shared page view saves on every
|
||||
/// change — the same immediate-persist rule as Settings — so a swipe past
|
||||
/// the page cannot lose a choice the user has already made.
|
||||
@State private var fuelSavings = OnboardingView.initialFuelSavings()
|
||||
|
||||
private let totalPages = 5
|
||||
private static let totalPages = 6
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -23,7 +35,7 @@ struct OnboardingView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
ForEach(0..<Self.totalPages, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
|
||||
.frame(width: 8, height: 8)
|
||||
@@ -39,7 +51,8 @@ struct OnboardingView: View {
|
||||
locationPage.tag(1)
|
||||
notificationsPage.tag(2)
|
||||
dataPage.tag(3)
|
||||
donePage.tag(4)
|
||||
bestValuePage.tag(4)
|
||||
donePage.tag(5)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
@@ -85,7 +98,7 @@ struct OnboardingView: View {
|
||||
private var welcomePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
||||
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
|
||||
// The app icon itself, with the standard iOS rounded-corner
|
||||
// treatment (~22% of the size), so onboarding matches the
|
||||
// Home Screen.
|
||||
@@ -95,25 +108,26 @@ struct OnboardingView: View {
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text("Welcome to FuelBoard")
|
||||
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.")
|
||||
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 12)
|
||||
|
||||
featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day")
|
||||
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
|
||||
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
|
||||
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
|
||||
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
|
||||
featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station")
|
||||
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -129,10 +143,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Prices near you")
|
||||
Text(String(localized: "Prices near you", comment: "Onboarding location permission title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.")
|
||||
Text(String(localized: "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.", comment: "Onboarding location permission explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -142,7 +156,7 @@ struct OnboardingView: View {
|
||||
permissionStatusLabel(
|
||||
granted: prompter.locationGranted,
|
||||
denied: prompter.locationDenied,
|
||||
deniedText: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it."
|
||||
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||
)
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -160,10 +174,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Cheapest-station alerts")
|
||||
Text(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.")
|
||||
Text(String(localized: "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.", comment: "Onboarding notifications explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -173,7 +187,7 @@ struct OnboardingView: View {
|
||||
permissionStatusLabel(
|
||||
granted: prompter.notificationsGranted,
|
||||
denied: prompter.notificationsDenied,
|
||||
deniedText: "Notifications were denied — you can still use FuelBoard, just without alert banners."
|
||||
deniedText: String(localized: "Notifications were denied — you can still use FuelBoard, just without alert banners.", comment: "Onboarding notifications denied text")
|
||||
)
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -191,10 +205,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Prices, ready when you are")
|
||||
Text(String(localized: "Prices, ready when you are", comment: "Onboarding data title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
|
||||
Text(String(localized: "FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.", comment: "Onboarding data explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -205,6 +219,12 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Best Value page — the shared body, so the first-run page and the
|
||||
/// announcement existing users see can never drift apart.
|
||||
private var bestValuePage: some View {
|
||||
BestValueIntroContent(settings: $fuelSavings)
|
||||
}
|
||||
|
||||
private var donePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -218,10 +238,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("You're all set")
|
||||
Text(String(localized: "You're all set", comment: "Onboarding final title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.")
|
||||
Text(String(localized: "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.", comment: "Onboarding final explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -238,11 +258,9 @@ struct OnboardingView: View {
|
||||
private var bottomAction: some View {
|
||||
switch page {
|
||||
case 0:
|
||||
primaryButton("Continue") { page = 1 }
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 }
|
||||
case 1:
|
||||
primaryButton(
|
||||
prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access")
|
||||
) {
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||
if prompter.locationDenied {
|
||||
openSettings()
|
||||
} else if prompter.locationGranted {
|
||||
@@ -252,14 +270,12 @@ struct OnboardingView: View {
|
||||
// screen (after it's been read) and BEFORE advancing — so
|
||||
// the prompt never covers the next page's animation. Auto-
|
||||
// advance on grant moves us on once the user responds;
|
||||
// denying leaves the page's "Open Settings" path.
|
||||
// denying leaves the page's Settings path.
|
||||
prompter.requestLocation()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
primaryButton(
|
||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||
) {
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||
page = 3 // decided either way — move on
|
||||
} else {
|
||||
@@ -269,40 +285,43 @@ struct OnboardingView: View {
|
||||
}
|
||||
case 3:
|
||||
// No permission on this page — prices need none. Straight on.
|
||||
primaryButton("Continue") { page = 4 }
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 4 }
|
||||
case 4:
|
||||
// Best Value: the settings were saved when the toggle moved, so a
|
||||
// swipe past this page keeps the choice.
|
||||
primaryButton(BestValueIntroContent.primaryTitle) { page = 5 }
|
||||
default:
|
||||
primaryButton("Start Using FuelBoard") { finish() }
|
||||
primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
private func primaryButton(_ title: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||
OnboardingPrimaryButton(title: title, action: action)
|
||||
}
|
||||
|
||||
private func featureRow(icon: String, text: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 32)
|
||||
Text(text)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> some View {
|
||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||
Group {
|
||||
if granted {
|
||||
Label("Allowed", systemImage: "checkmark.circle.fill")
|
||||
Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else if denied {
|
||||
Text(deniedText)
|
||||
@@ -311,7 +330,7 @@ struct OnboardingView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
} else {
|
||||
Text("The system prompt will appear next.")
|
||||
Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -326,12 +345,51 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
// Anyone who goes through the whole of onboarding has now seen the Best
|
||||
// Value page — mark the announcement seen so they don't immediately get
|
||||
// the standalone one aimed at existing users.
|
||||
FuelStore.markBestValueIntroSeen()
|
||||
FuelStore.saveHasCompletedOnboarding(true)
|
||||
onFinish()
|
||||
}
|
||||
|
||||
// MARK: - QA hooks
|
||||
|
||||
/// DEBUG-only launch args in the same spirit as ContentView's `-tab` and
|
||||
/// `-skipOnboarding`, both no-ops in release:
|
||||
///
|
||||
/// - `-onboardingPage 4` opens straight on a page, so the screenshot harness
|
||||
/// can photograph a page it cannot swipe or tap its way to.
|
||||
/// - `-onboardingBestValue on|off` forces the Best Value page's choice, so
|
||||
/// both of its states can be captured without driving the toggle.
|
||||
private static func initialPage() -> Int {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let index = args.firstIndex(of: "-onboardingPage"),
|
||||
index + 1 < args.count,
|
||||
let value = Int(args[index + 1]),
|
||||
(0..<totalPages).contains(value) {
|
||||
return value
|
||||
}
|
||||
#endif
|
||||
return 0
|
||||
}
|
||||
|
||||
private 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 {
|
||||
case gentle
|
||||
case locationArrow
|
||||
@@ -341,6 +399,9 @@ private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
|
||||
let tint: Color
|
||||
var motion: Motion = .gentle
|
||||
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
||||
/// the app icon itself and reads better with no container around it.
|
||||
var showsHalo: Bool = true
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@State private var settled = false
|
||||
@@ -350,6 +411,7 @@ private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if showsHalo {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.10))
|
||||
.frame(width: 108, height: 108)
|
||||
@@ -360,6 +422,7 @@ private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
.frame(width: 118, height: 118)
|
||||
.scaleEffect(settled ? 1.03 : 0.97)
|
||||
.opacity(settled ? 1.0 : 0.55)
|
||||
}
|
||||
|
||||
content
|
||||
.scaleEffect(iconScale)
|
||||
|
||||
@@ -111,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
private var favourites: [FuelStation] = []
|
||||
private var fuel: FuelType = .e10
|
||||
private var radiusKM: Double = 3.0
|
||||
private var alertMode: AlertMode = .cheapest
|
||||
private var bestValueThresholdGBP: Double = 1.0
|
||||
private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
private var fuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
|
||||
private var lastNotified: [String: Date] = [:] // dedup per station
|
||||
private var enabled = false
|
||||
/// Dynamically registered categories for tie-choice notifications. Kept so
|
||||
@@ -135,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
radiusKM = FuelStore.effectiveAlertsRadiusKM(
|
||||
followsSearch: FuelStore.loadAlertsFollowsSearch(),
|
||||
manualKM: FuelStore.loadAlertsRadius())
|
||||
alertMode = FuelStore.loadAlertsMode()
|
||||
bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
fuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||
stations = FuelStore.loadStations()
|
||||
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
||||
}
|
||||
@@ -143,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
|
||||
/// get priority slots (a Diesel favourite must not grab a slot while
|
||||
/// monitoring Unleaded).
|
||||
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
|
||||
func update(
|
||||
stations: [FuelStation],
|
||||
favourites: [FavouriteEntry],
|
||||
fuel: FuelType,
|
||||
radiusKM: Double,
|
||||
alertMode: AlertMode = .cheapest,
|
||||
bestValueThresholdGBP: Double = 1.0,
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
) {
|
||||
// Fall back to the shared cache when called before the first fetch
|
||||
// completes (launch, background region-event wake).
|
||||
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
|
||||
@@ -152,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
: favourites.filter { $0.fuel == fuel }.map(\.station)
|
||||
self.fuel = fuel
|
||||
self.radiusKM = radiusKM
|
||||
self.alertMode = alertMode
|
||||
self.bestValueThresholdGBP = bestValueThresholdGBP
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
self.fuelSavingsAmount = fuelSavingsAmount
|
||||
|
||||
refreshDebugStatus()
|
||||
|
||||
@@ -169,17 +190,36 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
// Debug section only shows the CURRENT region problem.
|
||||
lastRegionError = nil
|
||||
|
||||
// Favourites first (guaranteed slots), then closest stations, max 18.
|
||||
// `self.favourites` is already filtered to the monitored fuel.
|
||||
// Favourites first (guaranteed slots). For Best Value mode, spend the
|
||||
// remaining iOS region slots on likely savings winners before falling
|
||||
// back to closest stations; the actual notification still rechecks the
|
||||
// winner after the geofence wakes the app.
|
||||
var candidates: [FuelStation] = self.favourites
|
||||
let favIDs = Set(self.favourites.map(\.id))
|
||||
let location = FuelStore.loadLocation()
|
||||
let others = stations
|
||||
.filter { !favIDs.contains($0.id) }
|
||||
.sorted { lhs, rhs in
|
||||
let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil }
|
||||
let others: [FuelStation]
|
||||