Compare commits
20
Commits
@@ -1,4 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
|
Builds/
|
||||||
|
build-*/
|
||||||
build-release/
|
build-release/
|
||||||
build-sim/
|
build-sim/
|
||||||
build-watch/
|
build-watch/
|
||||||
|
|||||||
+100
-2
@@ -1,6 +1,6 @@
|
|||||||
# FuelBoard App Store Submission Plan
|
# FuelBoard App Store Submission Plan
|
||||||
|
|
||||||
Updated: 2026-08-16 · Target: safe App Store submission.
|
Updated: 2026-09-26 · Target: safe App Store submission.
|
||||||
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
||||||
checklist: what must happen, who owns it, and the timeline.
|
checklist: what must happen, who owns it, and the timeline.
|
||||||
|
|
||||||
@@ -15,8 +15,104 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
|||||||
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
||||||
Onboarding requests WhenInUse only.
|
Onboarding requests WhenInUse only.
|
||||||
|
|
||||||
|
## StoreKit sandbox: verified working configuration and caveats
|
||||||
|
|
||||||
|
### Verified configuration
|
||||||
|
|
||||||
|
The FuelBoard consumable tip products are:
|
||||||
|
|
||||||
|
- `com.apt.fuelboard.tip099`
|
||||||
|
- `com.apt.fuelboard.tip299`
|
||||||
|
- `com.apt.fuelboard.tip499`
|
||||||
|
|
||||||
|
The app requests these exact **Product IDs** with StoreKit 2
|
||||||
|
`Product.products(for:)`; the UI names are display labels and are not used for
|
||||||
|
lookup. The app bundle ID, App Store Connect app, explicit App ID, development
|
||||||
|
team, and Sandbox Apple Account must belong to the same developer account.
|
||||||
|
|
||||||
|
Two test environments must remain distinct:
|
||||||
|
|
||||||
|
- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
|
||||||
|
in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
|
||||||
|
App Store Connect and confirms the app's StoreKit loading and purchase code.
|
||||||
|
- **Apple sandbox testing:** select **None** for StoreKit Configuration, then
|
||||||
|
use Product > Run on a physical device with the development-signed build and
|
||||||
|
a Sandbox Apple Account signed in under Settings > Developer. This exercises
|
||||||
|
the real App Store Connect catalog.
|
||||||
|
|
||||||
|
The local configuration returning all three products does not prove that the
|
||||||
|
Apple sandbox catalog is ready. Conversely, a successful local run showing `$`
|
||||||
|
does not mean the App Store Connect price is USD: `Product.displayPrice` is
|
||||||
|
formatted for the active StoreKit/device storefront. Local StoreKit prices are
|
||||||
|
placeholders and do not upload to App Store Connect.
|
||||||
|
|
||||||
|
### Availability versus pricing
|
||||||
|
|
||||||
|
App Store Connect has separate controls:
|
||||||
|
|
||||||
|
- **Availability** controls the storefronts where the IAP is offered. The
|
||||||
|
verified FuelBoard configuration uses **United Kingdom only**.
|
||||||
|
- **Price schedule** may show generated prices for **175 countries and
|
||||||
|
regions**. This is expected and does not make the IAP available in all 175
|
||||||
|
storefronts; it is Apple's price conversion schedule.
|
||||||
|
|
||||||
|
The Sandbox Apple Account's App Store Country or Region must match an enabled
|
||||||
|
IAP storefront. After changing the sandbox tester's storefront, sign out and
|
||||||
|
back in under Settings > Developer so the storefront change activates.
|
||||||
|
|
||||||
|
### Important propagation/cache caveat
|
||||||
|
|
||||||
|
During sandbox diagnosis, `Product.products(for:)` completed successfully but
|
||||||
|
returned zero products with the IAP availability set to UK. Temporarily
|
||||||
|
changing one product's availability to all regions caused it to appear in the
|
||||||
|
sandbox catalog; changing it back to UK left it working. This indicates an
|
||||||
|
Apple-side catalog refresh or propagation/cache issue, not a change required in
|
||||||
|
the StoreKit code. Apple documents that product metadata changes can take up to
|
||||||
|
approximately one hour to appear in sandbox.
|
||||||
|
|
||||||
|
If an otherwise valid product returns zero products:
|
||||||
|
|
||||||
|
1. Confirm the Sandbox Apple Account belongs to the same developer team.
|
||||||
|
2. Confirm its storefront is an enabled availability region.
|
||||||
|
3. Sign out and back in to the sandbox account on the device.
|
||||||
|
4. Delete/reinstall the development build and retry with StoreKit Configuration
|
||||||
|
set to **None**.
|
||||||
|
5. If necessary, temporarily broaden one product's availability to all
|
||||||
|
regions, allow propagation, verify the product, then restore the intended
|
||||||
|
availability and allow propagation again.
|
||||||
|
|
||||||
|
Record whether the diagnostic result is **request failed**, **completed with
|
||||||
|
zero products**, or **a partial product set**. These are different failure
|
||||||
|
classes. Do not add fallback prices or change product lookup to display names.
|
||||||
|
|
||||||
|
Sandbox testing does not require prior App Review approval of the IAP. It does
|
||||||
|
require active developer membership, an active Paid Applications Agreement,
|
||||||
|
complete banking and tax information, valid product metadata and pricing, the
|
||||||
|
correct app/team association, and a matching Sandbox Apple Account.
|
||||||
|
|
||||||
## Hard blockers (must happen before ANY submission)
|
## Hard blockers (must happen before ANY submission)
|
||||||
|
|
||||||
|
0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP
|
||||||
|
configuration and sandbox testing. App Store Connect →
|
||||||
|
Business > Paid Applications Agreement → View and Agree. **This is the most
|
||||||
|
common cause of a 2.1(b) "unable to purchase item / error message" rejection
|
||||||
|
— without it every sandbox purchase fails.** (REJECTED once for this:
|
||||||
|
review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.)
|
||||||
|
**Status 2026-09-25: agreement clicked Agree but shows "Pending user info" —
|
||||||
|
this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN)
|
||||||
|
and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business >
|
||||||
|
Payments & Tax until the agreement shows "Active" and every tab is green.
|
||||||
|
Only then do sandbox IAPs start working.
|
||||||
|
0. **[USER] Tip IAPs configured and available to the sandbox** — the three
|
||||||
|
consumables may exist but must ALSO have complete metadata, a current price,
|
||||||
|
and an enabled storefront availability. Prior App Review approval or
|
||||||
|
`Cleared for Sale` status is **not required for sandbox product discovery**.
|
||||||
|
They must be backed by the **In-App Purchase capability on the App ID**
|
||||||
|
`com.apt.fuelboard` (developer portal → Identifiers → App ID capability).
|
||||||
|
3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn
|
||||||
|
SettingsView.swift. Test product discovery and purchase with a Sandbox
|
||||||
|
Apple Account first: Settings → Support FuelBoard.
|
||||||
|
|
||||||
1. **[USER] Apple Developer Program membership** — the app has never been
|
1. **[USER] Apple Developer Program membership** — the app has never been
|
||||||
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
||||||
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
|
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
|
||||||
@@ -31,7 +127,9 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
|||||||
provisioning profile (or Xcode automatic signing once the account is in
|
provisioning profile (or Xcode automatic signing once the account is in
|
||||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||||
collects location. A one-page GitHub Pages privacy policy is fine.
|
collects location. Policy text is now in `PRIVACY.md`; publish it via the
|
||||||
|
FuelBoard marketing site or GitHub Pages and paste that public URL into App
|
||||||
|
Store Connect.
|
||||||
|
|
||||||
## Required tasks — owner [ME] (technical, do after account exists)
|
## Required tasks — owner [ME] (technical, do after account exists)
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.1</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
FBA000000000000000000001 /* DataRefreshMode.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
|
||||||
|
FBA000000000000000000002 /* FuelHistory.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
|
||||||
|
FBA000000000000000000003 /* FuelPriceProvider.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
|
||||||
|
FBA000000000000000000004 /* FuelStore.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
|
||||||
|
FBA000000000000000000005 /* MirrorFuelProvider.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
|
||||||
|
FBA000000000000000000006 /* SiriCheapestLookup.swift in FuelBoardShared */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
|
||||||
|
FBA000000000000000000007 /* WidgetLocationFetcher.swift in FuelBoardShared */ = {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 */; };
|
||||||
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -37,17 +48,6 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed App Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -59,6 +59,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed App Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -73,6 +84,19 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
FBA000000000000000000100 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; 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; };
|
||||||
|
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
|
||||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -108,6 +132,14 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
FBA000000000000000000302 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -139,9 +171,47 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
FBA000000000000000000200 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA000000000000000000201 /* Sources */,
|
||||||
|
FBA000000000000000000202 /* Tests */,
|
||||||
|
);
|
||||||
|
name = FuelBoardTests;
|
||||||
|
path = FuelBoardTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FBA000000000000000000201 = {
|
||||||
|
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 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA000000000000000000108 /* FuelBoardTests.swift */,
|
||||||
|
FBA000000000000000000109 /* FuelHistoryTests.swift */,
|
||||||
|
FBA00000000000000000010A /* LiveChainTests.swift */,
|
||||||
|
);
|
||||||
|
name = Tests;
|
||||||
|
path = Tests/FuelBoardSharedTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
BB000000000000000000000D = {
|
BB000000000000000000000D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FBA000000000000000000200 /* FuelBoardTests */,
|
||||||
|
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
|
||||||
BB0000000000000000000008 /* FuelBoard */,
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||||
BB000000000000000000000A /* Shared */,
|
BB000000000000000000000A /* Shared */,
|
||||||
@@ -151,7 +221,7 @@
|
|||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
BB000000000000000000000E /* Products */ = {
|
BB000000000000000000000E = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
BB0000000000000000000007 /* FuelBoard.app */,
|
BB0000000000000000000007 /* FuelBoard.app */,
|
||||||
@@ -165,9 +235,26 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
FBA000000000000000000301 /* Sources */,
|
||||||
|
FBA000000000000000000302 /* Frameworks */,
|
||||||
|
FBA000000000000000000303 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = FuelBoardSharedTests;
|
||||||
|
productName = FuelBoardSharedTests;
|
||||||
|
productReference = FBA000000000000000000100 /* FuelBoardSharedTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
ADCE5B4B3033A329008C131C /* Sources */,
|
ADCE5B4B3033A329008C131C /* Sources */,
|
||||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||||
@@ -278,6 +365,11 @@
|
|||||||
};
|
};
|
||||||
BB000000000000000000000F = {
|
BB000000000000000000000F = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.InAppPurchase = {
|
||||||
|
enabled = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -299,11 +391,19 @@
|
|||||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||||
|
FBA000000000000000000300 /* FuelBoardSharedTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
FBA000000000000000000303 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
ADCE5B4D3033A329008C131C /* Resources */ = {
|
ADCE5B4D3033A329008C131C /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -335,6 +435,23 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
FBA000000000000000000301 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
FBA000000000000000000001 /* DataRefreshMode.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000002 /* FuelHistory.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000003 /* FuelPriceProvider.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000004 /* FuelStore.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000005 /* MirrorFuelProvider.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000006 /* SiriCheapestLookup.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000007 /* WidgetLocationFetcher.swift in FuelBoardShared */,
|
||||||
|
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
|
||||||
|
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
|
||||||
|
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
ADCE5B4B3033A329008C131C /* Sources */ = {
|
ADCE5B4B3033A329008C131C /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -405,8 +522,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -418,14 +536,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -471,8 +588,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -484,14 +602,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -537,7 +654,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -549,13 +666,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -602,7 +719,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -614,13 +731,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -698,8 +815,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -707,9 +825,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -721,8 +840,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -730,9 +850,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -745,7 +866,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -754,7 +875,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -770,7 +891,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -779,7 +900,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -789,10 +910,65 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000401 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
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 = 22;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
FBA000000000000000000401 /* Debug */,
|
||||||
|
FBA000000000000000000402 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
ADCE5B573033A32B008C131C /* Debug */,
|
ADCE5B573033A32B008C131C /* Debug */,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconArtwork.png",
|
"filename" : "AppIconArtwork.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconPreview.png",
|
"filename" : "AppIconPreview.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconPreviewDark.png",
|
"filename" : "AppIconPreviewDark.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "PreviewWallpaper.jpg",
|
"filename" : "PreviewWallpaper.jpg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_applegreen_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_applegreen_3x.png",
|
"filename" : "brand_applegreen_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_applegreen_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_asda_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_asda_3x.png",
|
"filename" : "brand_asda_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_asda_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_bp_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_bp_3x.png",
|
"filename" : "brand_bp_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_bp_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_circle_k_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_circle_k_3x.png",
|
"filename" : "brand_circle_k_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_circle_k_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_essar_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_essar_3x.png",
|
"filename" : "brand_essar_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_essar_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_esso_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_esso_3x.png",
|
"filename" : "brand_esso_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_esso_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_gleaner_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_gleaner_3x.png",
|
"filename" : "brand_gleaner_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_gleaner_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_gulf_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_gulf_3x.png",
|
"filename" : "brand_gulf_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_gulf_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_highland_fuels_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_highland_fuels_3x.png",
|
"filename" : "brand_highland_fuels_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_highland_fuels_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_jet_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_jet_3x.png",
|
"filename" : "brand_jet_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_jet_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_maxol_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_maxol_3x.png",
|
"filename" : "brand_maxol_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_maxol_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_morrisons_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_morrisons_3x.png",
|
"filename" : "brand_morrisons_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_morrisons_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_murco_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_murco_3x.png",
|
"filename" : "brand_murco_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_murco_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_sainsburys_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_sainsburys_3x.png",
|
"filename" : "brand_sainsburys_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_sainsburys_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_shell_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_shell_3x.png",
|
"filename" : "brand_shell_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_shell_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_spar_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_spar_3x.png",
|
"filename" : "brand_spar_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_spar_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_tesco_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_tesco_3x.png",
|
"filename" : "brand_tesco_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_tesco_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_texaco_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_texaco_3x.png",
|
"filename" : "brand_texaco_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_texaco_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_the_co_operative_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_the_co_operative_3x.png",
|
"filename" : "brand_the_co_operative_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_the_co_operative_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_valero_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_valero_3x.png",
|
"filename" : "brand_valero_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_valero_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename": "brand_welcome_break_2x.png",
|
"idiom" : "universal",
|
||||||
"idiom": "universal",
|
"scale" : "1x"
|
||||||
"scale": "2x"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename": "brand_welcome_break_3x.png",
|
"filename" : "brand_welcome_break_2x.png",
|
||||||
"idiom": "universal",
|
"idiom" : "universal",
|
||||||
"scale": "3x"
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "brand_welcome_break_3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info": {
|
"info" : {
|
||||||
"author": "xcode",
|
"author" : "xcode",
|
||||||
"version": 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct ContentView: View {
|
|||||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@@ -234,6 +235,30 @@ struct ContentView: View {
|
|||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
StationMapView(request: request)
|
StationMapView(request: request)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $monitor.pendingDetailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||||
|
let info = note.userInfo ?? [:]
|
||||||
|
if let id = info["stationID"] as? String,
|
||||||
|
let station = stations.first(where: { $0.id == id }) {
|
||||||
|
monitor.pendingDetailStation = station
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Station left the cache — fall back to the map sheet so
|
||||||
|
// the tap still shows something.
|
||||||
|
if let lat = info["stationLat"] as? Double,
|
||||||
|
let lng = info["stationLng"] as? Double {
|
||||||
|
let name = info["stationName"] as? String ?? "Station"
|
||||||
|
monitor.pendingStationMap = StationMapRequest(
|
||||||
|
name: name, latitude: lat, longitude: lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,6 +767,7 @@ struct ContentView: View {
|
|||||||
topStationID: topStationID,
|
topStationID: topStationID,
|
||||||
location: location,
|
location: location,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
@@ -756,6 +782,7 @@ struct ContentView: View {
|
|||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
@@ -794,6 +821,7 @@ struct ContentView: View {
|
|||||||
tipStore: tipStore,
|
tipStore: tipStore,
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
|
stationTapAction: $stationTapAction,
|
||||||
dataRefreshMode: $dataRefreshMode,
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
@@ -931,7 +959,9 @@ struct StationRow: View {
|
|||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
|
var tapAction: StationTapAction = .openMap
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
private var ragColor: Color {
|
private var ragColor: Color {
|
||||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||||
@@ -951,6 +981,21 @@ struct StationRow: View {
|
|||||||
return String(format: "%+.1fp", delta)
|
return String(format: "%+.1fp", delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleTap() {
|
||||||
|
switch tapAction {
|
||||||
|
case .openMap:
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
case .showDetails:
|
||||||
|
showDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDetails() {
|
||||||
|
onShowDetails(station)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||||
@@ -987,6 +1032,12 @@ struct StationRow: View {
|
|||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
}
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Image(systemName: notice.symbolName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(notice.title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("\(station.address), \(station.postcode)")
|
Text("\(station.address), \(station.postcode)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -1029,11 +1080,8 @@ struct StationRow: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture(perform: handleTap)
|
||||||
if let url = station.mapsDirectionsURL {
|
.onLongPressGesture(perform: showDetails)
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct FavouritesView: View {
|
|||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -37,6 +38,9 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
private var activeFuel: FuelType {
|
private var activeFuel: FuelType {
|
||||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,7 @@ struct FavouritesView: View {
|
|||||||
location: Coordinate?,
|
location: Coordinate?,
|
||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
stationTapAction: StationTapAction,
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -74,6 +79,7 @@ struct FavouritesView: View {
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.distanceUnit = distanceUnit
|
self.distanceUnit = distanceUnit
|
||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.stationTapAction = stationTapAction
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -129,7 +135,9 @@ struct FavouritesView: View {
|
|||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: index == 0,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveFavourite)
|
.onMove(perform: moveFavourite)
|
||||||
@@ -171,6 +179,13 @@ struct FavouritesView: View {
|
|||||||
onHistoryRecovered: onHistoryRecovered
|
onHistoryRecovered: onHistoryRecovered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
|||||||
@@ -69,9 +69,40 @@ struct FuelBoardApp: App {
|
|||||||
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
||||||
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
||||||
/// Apple Maps without ever launching this app.
|
/// Apple Maps without ever launching this app.
|
||||||
|
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
|
||||||
|
/// widget or Live Activity tap when Settings → Tap station = More info)
|
||||||
|
/// posts a notification that ContentView observes to present the detail
|
||||||
|
/// sheet (falling back to the map sheet when the station has left
|
||||||
|
/// the cache).
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let items = components.queryItems
|
else { return }
|
||||||
|
|
||||||
|
if components.scheme == "fuelboard",
|
||||||
|
components.host == "station" {
|
||||||
|
let items = components.queryItems ?? []
|
||||||
|
var userInfo: [String: Any] = [:]
|
||||||
|
if let id = items.first(where: { $0.name == "stationID" })?.value {
|
||||||
|
userInfo["stationID"] = id
|
||||||
|
}
|
||||||
|
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLat"] = lat
|
||||||
|
}
|
||||||
|
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLng"] = lng
|
||||||
|
}
|
||||||
|
if let name = items.first(where: { $0.name == "name" })?.value {
|
||||||
|
userInfo["stationName"] = name
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .fuelBoardShowStationDetail,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = components.queryItems
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@Published var pendingStationMap: StationMapRequest?
|
||||||
|
/// Station tapped via notification when Settings → Tap station = More info.
|
||||||
|
/// ContentView presents this as a StationDetailView sheet.
|
||||||
|
@Published var pendingDetailStation: FuelStation?
|
||||||
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@Published private(set) var debugStatus: DebugLocationStatus?
|
||||||
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tapping a notification opens Apple Maps directions to the station from
|
/// Tapping a notification honours Settings → Tap station: Open map opens
|
||||||
/// the user's current location. If Apple Maps can't be opened (e.g. inside
|
/// Apple Maps directions to the station from the user's current location;
|
||||||
/// LiveContainer), an in-app map sheet with directions is shown instead.
|
/// More info opens the station's detail sheet in the app instead. If Apple
|
||||||
|
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
|
||||||
|
/// with directions is shown instead.
|
||||||
///
|
///
|
||||||
/// When the notification carried tie-choice action buttons, the tapped
|
/// When the notification carried tie-choice action buttons, the tapped
|
||||||
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
||||||
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let tieName = ties[index]["name"] as? String,
|
let tieName = ties[index]["name"] as? String,
|
||||||
let tieLat = ties[index]["lat"] as? Double,
|
let tieLat = ties[index]["lat"] as? Double,
|
||||||
let tieLng = ties[index]["lng"] as? Double {
|
let tieLng = ties[index]["lng"] as? Double {
|
||||||
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
|
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
|
||||||
} else if !isTie, let name, let lat, let lng {
|
} else if !isTie, let name, let lat, let lng {
|
||||||
openDirections(to: name, latitude: lat, longitude: lng)
|
routeNotificationTap(name: name, latitude: lat, longitude: lng)
|
||||||
}
|
}
|
||||||
// Tie body tap: intentionally no directions — the user must pick
|
// Tie body tap: intentionally no directions — the user must pick
|
||||||
// an option button to choose a station.
|
// an option button to choose a station.
|
||||||
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a resolved notification tap through the user's Tap station
|
||||||
|
/// preference: Open map → Apple Maps; More info → in-app detail sheet
|
||||||
|
/// (falling back to the map sheet when the station isn't in cache).
|
||||||
|
@MainActor
|
||||||
|
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
|
||||||
|
guard FuelStore.loadStationTapAction() == .showDetails else {
|
||||||
|
openDirections(to: name, latitude: latitude, longitude: longitude)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
|
||||||
|
?? stations.first(where: { $0.name == name }) {
|
||||||
|
pendingDetailStation = station
|
||||||
|
} else {
|
||||||
|
// Station not in cache (e.g. data refreshed since the alert fired)
|
||||||
|
// — fall back to the map sheet so the tap still shows something.
|
||||||
|
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps with driving directions to the station; falls back to
|
/// Opens Apple Maps with driving directions to the station; falls back to
|
||||||
/// the in-app map sheet when the hand-off fails.
|
/// the in-app map sheet when the hand-off fails.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
+200
-23
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var tipStore: TipStore
|
@ObservedObject var tipStore: TipStore
|
||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
|
@Binding var stationTapAction: StationTapAction
|
||||||
@Binding var dataRefreshMode: DataRefreshMode
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
@@ -123,6 +124,22 @@ struct SettingsView: View {
|
|||||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Tap station", selection: $stationTapAction) {
|
||||||
|
ForEach(StationTapAction.allCases) { action in
|
||||||
|
Text(action.displayName).tag(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: stationTapAction) { _, newValue in
|
||||||
|
FuelStore.saveStationTapAction(newValue)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tap action")
|
||||||
|
} footer: {
|
||||||
|
Text("Tapping a station either opens directions straight away or shows its details first.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Data checking", selection: $dataRefreshMode) {
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
ForEach(DataRefreshMode.allCases) { mode in
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
@@ -232,6 +249,55 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.onAppear { refreshWidgetDiag() }
|
.onAppear { refreshWidgetDiag() }
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task { await tipStore.load() }
|
||||||
|
} label: {
|
||||||
|
Label("Probe App Store products", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
LabeledContent("Probe", value: tipStore.productLoadStatus)
|
||||||
|
if let loadedAt = tipStore.lastProductLoadDate {
|
||||||
|
LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard))
|
||||||
|
}
|
||||||
|
LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs")
|
||||||
|
LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products")
|
||||||
|
if !tipStore.debugProducts.isEmpty {
|
||||||
|
ForEach(tipStore.debugProducts) { product in
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(product.id)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Text(product.displayName)
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
Text("\(product.type) · \(product.displayPrice) · raw \(product.price)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No products were returned by StoreKit.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if !tipStore.missingProductIDs.isEmpty {
|
||||||
|
Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if let error = tipStore.lastProductLoadError {
|
||||||
|
Text("Error: \(error)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("IAP Diagnostics")
|
||||||
|
} footer: {
|
||||||
|
Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Connection")
|
Text("Connection")
|
||||||
@@ -614,19 +680,18 @@ final class TipStore: ObservableObject {
|
|||||||
let id: String // product ID
|
let id: String // product ID
|
||||||
let name: String
|
let name: String
|
||||||
let blurb: String
|
let blurb: String
|
||||||
let fallbackPrice: String
|
|
||||||
let accent: Color // card/price accent (matches the fuel palette)
|
let accent: Color // card/price accent (matches the fuel palette)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let tiers: [TipTier] = [
|
static let tiers: [TipTier] = [
|
||||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||||
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
blurb: "Just enough to keep things moving.",
|
||||||
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||||
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
blurb: "A generous top-up for development.",
|
||||||
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||||
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
blurb: "Keeping the app on the road.",
|
||||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -640,6 +705,22 @@ final class TipStore: ObservableObject {
|
|||||||
@Published private(set) var products: [String: Product] = [:]
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
@Published private(set) var purchaseInProgress = false
|
@Published private(set) var purchaseInProgress = false
|
||||||
@Published private(set) var outcome: TipOutcome?
|
@Published private(set) var outcome: TipOutcome?
|
||||||
|
@Published private(set) var productLoadStatus = "Not probed"
|
||||||
|
@Published private(set) var lastProductLoadDate: Date?
|
||||||
|
@Published private(set) var lastProductLoadError: String?
|
||||||
|
@Published private(set) var debugProducts: [DebugProduct] = []
|
||||||
|
|
||||||
|
struct DebugProduct: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let type: String
|
||||||
|
let displayPrice: String
|
||||||
|
let price: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingProductIDs: [String] {
|
||||||
|
Self.tiers.map(\.id).filter { products[$0] == nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||||
private var dismissTask: Task<Void, Never>?
|
private var dismissTask: Task<Void, Never>?
|
||||||
@@ -650,9 +731,31 @@ final class TipStore: ObservableObject {
|
|||||||
/// the queue but are never finished or acknowledged.
|
/// the queue but are never finished or acknowledged.
|
||||||
private var updatesTask: Task<Void, Never>?
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Drains verified FuelBoard transactions that were already unfinished
|
||||||
|
/// before this store was created (for example after termination during a
|
||||||
|
/// sandbox purchase). `Transaction.updates` only covers updates delivered
|
||||||
|
/// while the listener is active, so both sequences are required.
|
||||||
|
private var unfinishedTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Uses the existing hidden developer switch in Settings. StoreKit
|
||||||
|
/// diagnostics must stay silent unless the developer explicitly enables
|
||||||
|
/// Debug mode with the five-tap Version gesture.
|
||||||
|
private static func debugLog(_ message: @autoclosure () -> String) {
|
||||||
|
guard FuelStore.loadDebugMode() else { return }
|
||||||
|
print(message())
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
|
||||||
updatesTask = Task { [weak self] in
|
updatesTask = Task { [weak self] in
|
||||||
for await update in Transaction.updates {
|
for await update in Transaction.updates {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
|
||||||
|
await self?.handle(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unfinishedTask = Task { [weak self] in
|
||||||
|
for await update in Transaction.unfinished {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received")
|
||||||
await self?.handle(update)
|
await self?.handle(update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,14 +763,27 @@ final class TipStore: ObservableObject {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
|
unfinishedTask?.cancel()
|
||||||
dismissTask?.cancel()
|
dismissTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
|
||||||
// Never deliver or finish an untrusted purchase.
|
// Never deliver or finish an untrusted purchase.
|
||||||
guard case .verified(let transaction) = update else { return }
|
guard case .verified(let transaction) = update else {
|
||||||
|
if case .unverified(_, let error) = update {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
// Only acknowledge our own consumables (future products get their own).
|
// Only acknowledge our own consumables (future products get their own).
|
||||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Ignoring unrelated product ID=\(transaction.productID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Finishing verified transaction productID=\(transaction.productID)")
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
@@ -696,45 +812,101 @@ final class TipStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func displayPrice(for tier: TipTier) -> String {
|
func displayPrice(for tier: TipTier) -> String {
|
||||||
products[tier.id]?.displayPrice ?? tier.fallbackPrice
|
products[tier.id]?.displayPrice
|
||||||
|
?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit")
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
// Refreshes product state on every visit so newly-approved products
|
// Refreshes product state on every visit so newly-approved products
|
||||||
// (or restored transactions) are picked up.
|
// (or restored transactions) are picked up.
|
||||||
|
let ids = Self.tiers.map(\.id)
|
||||||
|
productLoadStatus = "Loading…"
|
||||||
|
lastProductLoadError = nil
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
|
||||||
do {
|
do {
|
||||||
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
let fetched = try await Product.products(for: ids)
|
||||||
|
lastProductLoadDate = Date()
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
||||||
|
for product in fetched {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)")
|
||||||
|
}
|
||||||
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||||
|
debugProducts = fetched.map {
|
||||||
|
DebugProduct(
|
||||||
|
id: $0.id,
|
||||||
|
displayName: $0.displayName,
|
||||||
|
type: String(describing: $0.type),
|
||||||
|
displayPrice: $0.displayPrice,
|
||||||
|
price: String(describing: $0.price)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed"
|
||||||
|
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
|
||||||
} catch {
|
} catch {
|
||||||
// No products yet (sideloaded build) — the buttons still show the
|
lastProductLoadDate = Date()
|
||||||
// intended prices and report purchase attempts gracefully.
|
productLoadStatus = "Failed"
|
||||||
|
lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)"
|
||||||
|
debugProducts = []
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||||
|
// Keep the cards visible so the user can retry while App Store
|
||||||
|
// Connect metadata propagates; displayPrice() shows "Unavailable"
|
||||||
|
// until StoreKit returns a real product.
|
||||||
products = [:]
|
products = [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func merge(_ fetched: [Product]) {
|
||||||
|
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func product(for tier: TipTier) async throws -> Product? {
|
||||||
|
if let product = products[tier.id] {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)")
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlight/App Store Connect product availability can lag the Settings
|
||||||
|
// onAppear load. Re-query the tapped tier before falling back so a newly
|
||||||
|
// approved IAP opens the system purchase sheet instead of showing stale
|
||||||
|
// "not available" copy.
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
|
||||||
|
let fetched = try await Product.products(for: [tier.id])
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
||||||
|
merge(fetched)
|
||||||
|
let match = fetched.first { $0.id == tier.id }
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)")
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
func purchase(_ tier: TipTier) async {
|
func purchase(_ tier: TipTier) async {
|
||||||
guard !purchaseInProgress else { return }
|
Self.debugLog("[FuelBoard StoreKit] Purchase requested ID=\(tier.id) name=\(tier.name)")
|
||||||
|
guard !purchaseInProgress else {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase ignored because another purchase is in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
purchaseInProgress = true
|
purchaseInProgress = true
|
||||||
defer { purchaseInProgress = false }
|
defer { purchaseInProgress = false }
|
||||||
|
|
||||||
// If the product hasn't loaded (e.g. not configured in App Store
|
|
||||||
// Connect yet), still allow the attempt so the user sees a clear
|
|
||||||
// outcome rather than a dead button.
|
|
||||||
guard let product = products[tier.id] else {
|
|
||||||
showOutcome(
|
|
||||||
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
|
||||||
icon: "info.circle.fill",
|
|
||||||
tint: Color(.secondaryLabel)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
guard let product = try await product(for: tier) else {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
|
||||||
|
showOutcome(
|
||||||
|
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
|
||||||
|
icon: "info.circle.fill",
|
||||||
|
tint: Color(.secondaryLabel)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
|
||||||
let result = try await product.purchase()
|
let result = try await product.purchase()
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
|
||||||
switch verification {
|
switch verification {
|
||||||
case .verified(let transaction):
|
case .verified(let transaction):
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
|
||||||
// Consume the consumable — otherwise StoreKit re-delivers
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
// it through Transaction.updates on every launch.
|
// it through Transaction.updates on every launch.
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
@@ -744,6 +916,7 @@ final class TipStore: ObservableObject {
|
|||||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
)
|
)
|
||||||
case .unverified:
|
case .unverified:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||||
icon: "exclamationmark.triangle.fill",
|
icon: "exclamationmark.triangle.fill",
|
||||||
@@ -751,17 +924,21 @@ final class TipStore: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
|
||||||
dismissOutcome() // silent — the user just closed the sheet
|
dismissOutcome() // silent — the user just closed the sheet
|
||||||
case .pending:
|
case .pending:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||||
icon: "hourglass",
|
icon: "hourglass",
|
||||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||||
)
|
)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
|
||||||
dismissOutcome()
|
dismissOutcome()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||||
icon: "xmark.circle.fill",
|
icon: "xmark.circle.fill",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||||
|
/// Shows a small map preview, the station name, address, distance from the
|
||||||
|
/// current location, and a Directions button that opens Apple Maps — the same
|
||||||
|
/// destination a direct tap would have opened.
|
||||||
|
struct StationDetailView: View {
|
||||||
|
let station: FuelStation
|
||||||
|
let location: Coordinate?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showingReportIssueConfirmation = false
|
||||||
|
|
||||||
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
|
private var stationCoordinate: CLLocationCoordinate2D {
|
||||||
|
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Map(position: .constant(.region(MKCoordinateRegion(
|
||||||
|
center: stationCoordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
|
||||||
|
)))) {
|
||||||
|
Marker(station.name, coordinate: stationCoordinate)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
LabeledContent("Name", value: station.name)
|
||||||
|
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
|
||||||
|
if let location {
|
||||||
|
let km = FuelStore.displayDistanceKM(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
LabeledContent("Distance", value: distanceUnit.format(km))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showingReportIssueConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(station.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Report") {
|
||||||
|
UIApplication.shared.open(reportIssueURL)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Presentation-only notices derived from existing station metadata.
|
||||||
|
///
|
||||||
|
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
|
||||||
|
/// navigation. They are intentionally small and reusable so additional
|
||||||
|
/// informational station notices can be added later from the UI layer.
|
||||||
|
enum StationNotice: Identifiable, Equatable {
|
||||||
|
case membershipRequired
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "membershipRequired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "Membership required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "creditcard.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notices(for station: FuelStation) -> [StationNotice] {
|
||||||
|
isCostco(station) ? [.membershipRequired] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCostco(_ station: FuelStation) -> Bool {
|
||||||
|
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsCostcoToken(_ value: String) -> Bool {
|
||||||
|
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
|
||||||
|
return tokens.contains("COSTCO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ struct StationsView: View {
|
|||||||
let topStationID: String?
|
let topStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
/// directions context + "N/TOTAL stations updated". The numerator is the
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
/// current list pool; the denominator is the full UK station total from
|
/// current list pool; the denominator is the full UK station total from
|
||||||
@@ -38,6 +42,9 @@ struct StationsView: View {
|
|||||||
let unit = distanceUnit.label(for: Double(miles))
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
let fuel = selectedFuel.displayName
|
let fuel = selectedFuel.displayName
|
||||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||||
|
let tapHint = stationTapAction == .openMap
|
||||||
|
? "tap a station for directions."
|
||||||
|
: "tap a station for details."
|
||||||
let ratio: String
|
let ratio: String
|
||||||
if let total = FuelStore.loadStationCount() {
|
if let total = FuelStore.loadStationCount() {
|
||||||
ratio = "\(totalCount)/\(total)"
|
ratio = "\(totalCount)/\(total)"
|
||||||
@@ -48,9 +55,9 @@ struct StationsView: View {
|
|||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -126,7 +133,9 @@ struct StationsView: View {
|
|||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +269,13 @@ struct StationsView: View {
|
|||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import WatchConnectivity
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -10,6 +11,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
static let shared = WatchSyncManager()
|
static let shared = WatchSyncManager()
|
||||||
|
|
||||||
private var pendingSnapshotPush = false
|
private var pendingSnapshotPush = false
|
||||||
|
private var activationRequested = false
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -17,19 +19,28 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
pendingSnapshotPush = true
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.isWatchAppInstalled else { return }
|
||||||
|
pendingSnapshotPush = true
|
||||||
if session.delegate !== self {
|
if session.delegate !== self {
|
||||||
session.delegate = self
|
session.delegate = self
|
||||||
}
|
}
|
||||||
|
guard session.activationState != .activated else { return }
|
||||||
|
guard !activationRequested else { return }
|
||||||
|
activationRequested = true
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushSnapshot() {
|
func pushSnapshot() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.isWatchAppInstalled else {
|
||||||
|
pendingSnapshotPush = false
|
||||||
|
return
|
||||||
|
}
|
||||||
guard session.activationState == .activated else {
|
guard session.activationState == .activated else {
|
||||||
pendingSnapshotPush = true
|
pendingSnapshotPush = true
|
||||||
|
activate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -72,6 +83,9 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.activationRequested = false
|
||||||
|
}
|
||||||
if let error {
|
if let error {
|
||||||
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
@@ -87,7 +101,8 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
WCSession.default.activate()
|
self.activationRequested = false
|
||||||
|
self.activate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@
|
|||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
||||||
|
"Tap station" = "Tap station";
|
||||||
|
"Tap action" = "Tap action";
|
||||||
|
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
||||||
|
"Open map" = "Open map";
|
||||||
|
"More info" = "More info";
|
||||||
|
"Name" = "Name";
|
||||||
|
"Address" = "Address";
|
||||||
|
"Distance" = "Distance";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
@@ -139,6 +147,12 @@
|
|||||||
"You" = "You";
|
"You" = "You";
|
||||||
"Done" = "Done";
|
"Done" = "Done";
|
||||||
"Directions" = "Directions";
|
"Directions" = "Directions";
|
||||||
|
"Report issue" = "Report issue";
|
||||||
|
"Membership required" = "Membership required";
|
||||||
|
"Report an error?" = "Report an error?";
|
||||||
|
"Cancel" = "Cancel";
|
||||||
|
"Report" = "Report";
|
||||||
|
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
|
||||||
|
|
||||||
/* Station row */
|
/* Station row */
|
||||||
"best" = "best";
|
"best" = "best";
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
|
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
|
||||||
// Covers the pure logic that drives the app + widget: title sanitizer, price
|
// Covers the pure logic that drives the app + widget: title sanitizer, price
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
// FuelHistory — price-history store for the Favourites Trends graph.
|
// FuelHistory — price-history store for the Favourites Trends graph.
|
||||||
// Covers day math, slim day parsing, series building, delta rebasing and
|
// Covers day math, slim day parsing, series building, delta rebasing and
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] →
|
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] →
|
||||||
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
|
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"identifier" : "fuelboard-local-tips",
|
||||||
|
"nonRenewingSubscriptions" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"displayPrice" : "0.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip099",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Just enough to keep things moving.",
|
||||||
|
"displayName" : "Splash & Dash",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip099",
|
||||||
|
"referenceName" : "FuelBoard Tip 099 - Splash & Dash",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "2.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip299",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A generous top-up for development.",
|
||||||
|
"displayName" : "Half a Tank",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip299",
|
||||||
|
"referenceName" : "FuelBoard Tip 299 - Half a Tank",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "4.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip499",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Keeping the app on the road.",
|
||||||
|
"displayName" : "Fill 'Er Up",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip499",
|
||||||
|
"referenceName" : "FuelBoard Tip 499 - Fill 'Er Up",
|
||||||
|
"type" : "Consumable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"subscriptionGroups" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"version" : {
|
||||||
|
"major" : 2,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ private enum WatchFuelCache {
|
|||||||
static let appGroupSuite = "group.com.apt.fuelboard"
|
static let appGroupSuite = "group.com.apt.fuelboard"
|
||||||
static let favouritesKey = "fuelboard.favourites"
|
static let favouritesKey = "fuelboard.favourites"
|
||||||
static let stationsKey = "fuelboard.stations"
|
static let stationsKey = "fuelboard.stations"
|
||||||
|
static let stationsFileName = "fuelboard.stations.json"
|
||||||
static let fuelKey = "fuelboard.selectedFuel"
|
static let fuelKey = "fuelboard.selectedFuel"
|
||||||
static let distanceUnitKey = "fuelboard.distanceUnit"
|
static let distanceUnitKey = "fuelboard.distanceUnit"
|
||||||
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
||||||
@@ -228,7 +229,17 @@ private enum WatchFuelCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadStations() -> [WatchStation] {
|
static func loadStations() -> [WatchStation] {
|
||||||
guard let data = defaults()?.data(forKey: stationsKey),
|
let fileData: Data?
|
||||||
|
if let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupSuite
|
||||||
|
) {
|
||||||
|
let url = container.appendingPathComponent(stationsFileName)
|
||||||
|
fileData = try? Data(contentsOf: url)
|
||||||
|
} else {
|
||||||
|
fileData = nil
|
||||||
|
}
|
||||||
|
let data = fileData ?? defaults()?.data(forKey: stationsKey)
|
||||||
|
guard let data,
|
||||||
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
private let suiteName = "group.com.apt.fuelboard"
|
private let suiteName = "group.com.apt.fuelboard"
|
||||||
private let snapshotRequestKey = "fuelboard.snapshotRequest"
|
private let snapshotRequestKey = "fuelboard.snapshotRequest"
|
||||||
|
private var activationRequested = false
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -21,14 +22,21 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
if session.delegate !== self {
|
if session.delegate !== self {
|
||||||
session.delegate = self
|
session.delegate = self
|
||||||
}
|
}
|
||||||
|
guard session.activationState != .activated else { return }
|
||||||
|
guard !activationRequested else { return }
|
||||||
|
activationRequested = true
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestRefresh(at date: Date = Date()) {
|
func requestRefresh(at date: Date = Date()) {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
let raw = String(date.timeIntervalSince1970)
|
let raw = String(date.timeIntervalSince1970)
|
||||||
let payload = ["fuelboard.watchRefreshRequest": raw]
|
let payload = ["fuelboard.watchRefreshRequest": raw]
|
||||||
let session = WCSession.default
|
|
||||||
|
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
@@ -39,8 +47,12 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
func requestSnapshot() {
|
func requestSnapshot() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
|
||||||
|
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
@@ -82,6 +94,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
activationRequested = false
|
||||||
if let error {
|
if let error {
|
||||||
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
//
|
//
|
||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
|
||||||
|
// directions to that station, More info opens the detail sheet in the app.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
@@ -31,22 +32,32 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
FuelBoardLiveActivityCompactView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityCompactView(context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
FuelBoardLiveActivityStationView(context: context)
|
FuelBoardLiveActivityStationView(context: context)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.foregroundStyle(context.state.fuel.tintColor)
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Text(context.state.priceText)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.font(.caption2.bold().monospacedDigit())
|
Text(context.state.priceText)
|
||||||
|
.font(.caption2.bold().monospacedDigit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.supplementalActivityFamilies([.small])
|
||||||
@@ -84,7 +95,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
@State private var slotWidth: CGFloat = 400
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: tapDestination) {
|
||||||
Group {
|
Group {
|
||||||
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// full Lock Screen width (>= threshold) → rich card, no matter
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
@@ -138,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
style: context.state.priceDisplayStyle,
|
style: context.state.priceDisplayStyle,
|
||||||
size: 22, weight: .bold)
|
size: 22, weight: .bold)
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -210,9 +222,26 @@ private struct FuelBoardLiveActivityStationView: View {
|
|||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.font(.caption)
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
.lineLimit(1)
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap routing shared by the Lock Screen card and the island: honours
|
||||||
|
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
|
||||||
|
enum FuelBoardLiveActivityTap {
|
||||||
|
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FuelBoardLiveActivityView {
|
||||||
|
/// Lock Screen tap honours Settings → Tap station.
|
||||||
|
var tapDestination: URL {
|
||||||
|
FuelBoardLiveActivityTap.tapDestination(for: context.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to the station's detail sheet, used when
|
||||||
|
/// Settings → Tap station = More info. Carries coordinate/name fallback
|
||||||
|
/// so the app can still show the map sheet when the station has left
|
||||||
|
/// the cache.
|
||||||
|
var detailURL: URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: stationID),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: stationName),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
# FuelBoard Privacy Policy
|
||||||
|
|
||||||
|
Effective date: 2026-09-19
|
||||||
|
|
||||||
|
FuelBoard helps users in the UK find nearby petrol, diesel, and premium fuel prices, save favourite stations, view widgets and Live Activities, and optionally receive location-based alerts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- FuelBoard does not track you across apps or websites.
|
||||||
|
- FuelBoard does not sell personal data.
|
||||||
|
- FuelBoard does not use advertising SDKs or analytics SDKs.
|
||||||
|
- FuelBoard uses location only to show nearby stations, calculate distances, power widgets, and provide optional fuel-price alerts.
|
||||||
|
- Most app preferences and cached data stay on your device.
|
||||||
|
- Fuel price data comes from public UK fuel-price datasets mirrored through GitHub raw content and a bundled offline snapshot.
|
||||||
|
|
||||||
|
## Data FuelBoard uses
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
FuelBoard may use your device location to:
|
||||||
|
|
||||||
|
- sort stations by distance;
|
||||||
|
- show stations within your chosen search radius;
|
||||||
|
- provide directions to a selected station;
|
||||||
|
- update home-screen widgets with nearby fuel prices;
|
||||||
|
- run optional geofenced alerts when you enable the Alerts feature.
|
||||||
|
|
||||||
|
On first launch, FuelBoard asks for When In Use location access. If you enable optional Alerts, iOS may ask you to allow Always location access so alerts can work while the app is not open.
|
||||||
|
|
||||||
|
FuelBoard stores your last known location on your device so it can refresh nearby results and widgets. FuelBoard does not sell or share your location for advertising.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
FuelBoard may request notification permission so it can send optional fuel-price alerts. Notifications are used only for FuelBoard features such as station alerts and test alerts.
|
||||||
|
|
||||||
|
### App preferences and favourites
|
||||||
|
|
||||||
|
FuelBoard stores settings such as favourite stations, fuel type, distance unit, price display style, alert settings, widget configuration, and cached public fuel-price data on your device using Apple storage APIs such as UserDefaults and Keychain.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
FuelBoard may send limited diagnostic beacons when developer relay fallback is enabled from the hidden Debug section. These requests are used to diagnose data-source and widget-refresh behaviour during testing. They are not used for advertising or tracking.
|
||||||
|
|
||||||
|
The consumer data path uses public HTTPS GitHub raw content and a bundled offline snapshot. Normal consumer use does not require a private local relay.
|
||||||
|
|
||||||
|
### Purchases
|
||||||
|
|
||||||
|
FuelBoard may offer optional consumable tip purchases. Payments are handled by Apple through StoreKit. FuelBoard does not receive or store your payment card details.
|
||||||
|
|
||||||
|
## Data FuelBoard does not collect
|
||||||
|
|
||||||
|
FuelBoard does not collect or access:
|
||||||
|
|
||||||
|
- contacts;
|
||||||
|
- photos or videos;
|
||||||
|
- camera or microphone content;
|
||||||
|
- browsing history;
|
||||||
|
- advertising identifiers for tracking;
|
||||||
|
- payment card details.
|
||||||
|
|
||||||
|
FuelBoard does not use App Tracking Transparency because it does not track users across apps or websites.
|
||||||
|
|
||||||
|
## Third-party services
|
||||||
|
|
||||||
|
FuelBoard may contact these services as part of normal app functionality:
|
||||||
|
|
||||||
|
- GitHub raw content, to download the public FuelBoard fuel-price mirror;
|
||||||
|
- Apple Maps, when you choose to open directions;
|
||||||
|
- Apple StoreKit, when optional tip purchases are available;
|
||||||
|
- Apple notification, widget, Live Activity, location, and keychain services provided by iOS.
|
||||||
|
|
||||||
|
Fuel price data is based on the UK Fuel Finder dataset made available under the Open Government Licence.
|
||||||
|
|
||||||
|
## Data retention
|
||||||
|
|
||||||
|
FuelBoard keeps local settings, favourites, cached station data, and cached price-history data on your device until you delete them, change the relevant settings, or delete the app. iOS may also remove cached app data according to system behaviour.
|
||||||
|
|
||||||
|
## Your choices
|
||||||
|
|
||||||
|
You can control FuelBoard's access at any time in iOS Settings:
|
||||||
|
|
||||||
|
- Location: Settings > Privacy & Security > Location Services > FuelBoard
|
||||||
|
- Notifications: Settings > Notifications > FuelBoard
|
||||||
|
- Widgets and Live Activities: managed through iOS widget and Live Activity controls
|
||||||
|
|
||||||
|
You can also delete FuelBoard to remove app data stored on the device, subject to normal iOS and iCloud/device-backup behaviour.
|
||||||
|
|
||||||
|
## Children's privacy
|
||||||
|
|
||||||
|
FuelBoard is not directed at children and does not knowingly collect personal information from children.
|
||||||
|
|
||||||
|
## Changes to this policy
|
||||||
|
|
||||||
|
This policy may be updated as FuelBoard changes. The effective date above will be updated when the policy changes.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For privacy questions, use FuelBoard's listed App Store support contact or the public project issue tracker associated with the app.
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
||||||
// Everything dynamic lives in ContentState so updates — including a fuel
|
// Everything dynamic lives in ContentState so updates — including a fuel
|
||||||
// change — apply to the running activity without needing to restart it.
|
// change — apply to the running activity without needing to restart it.
|
||||||
// Tapping the activity opens Apple Maps driving directions (maps://).
|
// Tapping the activity honours Settings → Tap station: Open map opens Apple
|
||||||
|
// Maps driving directions (maps://), More info opens the detail sheet in
|
||||||
|
// the app (fuelboard://station).
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -93,13 +93,14 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
if isWidget {
|
if isWidget {
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
|
||||||
row(station, cheapest: cheapest)
|
row(station, cheapest: cheapest)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
+98
-7
@@ -208,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Station tap action
|
||||||
|
|
||||||
|
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||||
|
/// open Apple Maps directions immediately (historic behaviour, default), or
|
||||||
|
/// show a detail sheet (name, address, distance, Directions button).
|
||||||
|
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case openMap // tap → Apple Maps directions straight away
|
||||||
|
case showDetails // tap → detail sheet, Directions button inside
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .openMap: return "Open map"
|
||||||
|
case .showDetails: return "More info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Station model
|
// MARK: - Station model
|
||||||
|
|
||||||
struct FuelStation: Identifiable, Codable, Equatable {
|
struct FuelStation: Identifiable, Codable, Equatable {
|
||||||
@@ -252,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to this station's More info sheet. Carries the
|
||||||
|
/// stationID plus coordinate/name fallback so the app can still show the
|
||||||
|
/// map sheet when the station has left the cache.
|
||||||
|
var detailDeepLinkURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: id),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget/Live Activity tap destination honouring Settings → Tap station:
|
||||||
|
/// Open map → Apple Maps directions; More info → in-app detail deep link.
|
||||||
|
var tapDestinationURL: URL? {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? detailDeepLinkURL
|
||||||
|
: widgetDirectionsURL
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the bundled brand logo asset, or nil if unknown.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
@@ -376,13 +419,37 @@ struct FuelStore {
|
|||||||
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
||||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
|
||||||
|
|
||||||
|
// Large shared payloads belong in the App Group container, not
|
||||||
|
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
|
||||||
|
static let stationsFileName = "fuelboard.stations.json"
|
||||||
|
|
||||||
|
static func appGroupFileURL(_ name: String) -> URL? {
|
||||||
|
FileManager.default
|
||||||
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)?
|
||||||
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadAppGroupFile(_ name: String) -> Data? {
|
||||||
|
guard let url = appGroupFileURL(name) else { return nil }
|
||||||
|
return try? Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func saveAppGroupFile(_ data: Data, name: String) -> Bool {
|
||||||
|
guard let url = appGroupFileURL(name) else { return false }
|
||||||
|
do {
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Stations
|
// MARK: Stations
|
||||||
// The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only —
|
// The full-UK dataset is stored as an App Group file. UserDefaults is
|
||||||
// keychain is for small values and cannot hold it. Read order is
|
// reserved for small preferences and has a roughly 4 MB total limit.
|
||||||
// defaults-first for stations (keychain may hold a legacy small set from
|
|
||||||
// older builds; the full country dump always wins).
|
|
||||||
|
|
||||||
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
|
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
|
||||||
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
|
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
|
||||||
@@ -401,10 +468,19 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadStations() -> [FuelStation] {
|
static func loadStations() -> [FuelStation] {
|
||||||
|
if let data = loadAppGroupFile(stationsFileName),
|
||||||
|
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||||
|
!stations.isEmpty {
|
||||||
|
return stations.map(sanitized)
|
||||||
|
}
|
||||||
|
// Migrate the legacy App Group UserDefaults value once.
|
||||||
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
||||||
let data = defaults.data(forKey: stationsKey),
|
let data = defaults.data(forKey: stationsKey),
|
||||||
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||||
!stations.isEmpty {
|
!stations.isEmpty {
|
||||||
|
if saveAppGroupFile(data, name: stationsFileName) {
|
||||||
|
defaults.removeObject(forKey: stationsKey)
|
||||||
|
}
|
||||||
return stations.map(sanitized)
|
return stations.map(sanitized)
|
||||||
}
|
}
|
||||||
if let data = keychainData(service: stationsKey),
|
if let data = keychainData(service: stationsKey),
|
||||||
@@ -425,8 +501,7 @@ struct FuelStore {
|
|||||||
|
|
||||||
static func saveStations(_ stations: [FuelStation]) {
|
static func saveStations(_ stations: [FuelStation]) {
|
||||||
if let data = try? JSONEncoder().encode(stations) {
|
if let data = try? JSONEncoder().encode(stations) {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
|
_ = saveAppGroupFile(data, name: stationsFileName)
|
||||||
// Intentionally NOT written to keychain — 2.9 MB exceeds its limits.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +617,22 @@ struct FuelStore {
|
|||||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
||||||
|
// default open map preserves the historic tap behaviour for existing installs.
|
||||||
|
|
||||||
|
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||||
|
|
||||||
|
static func loadStationTapAction() -> StationTapAction {
|
||||||
|
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return .openMap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveStationTapAction(_ action: StationTapAction) {
|
||||||
|
saveString(action.rawValue, service: stationTapActionKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
||||||
static var latestMeta: RelayMeta?
|
static var latestMeta: RelayMeta?
|
||||||
|
|
||||||
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
/// App-group file cache of the last fetched FULL dump, keyed by snapshot day.
|
||||||
/// The mirror pushes once/day, so between pushes the app reuses the
|
/// The mirror pushes once/day, so between pushes the app reuses the
|
||||||
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
||||||
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
||||||
|
static let liveDumpCacheFileName = "fuelboard.liveDumpCache.json"
|
||||||
|
|
||||||
struct DumpCache: Codable {
|
struct DumpCache: Codable {
|
||||||
let day: String
|
let day: String
|
||||||
@@ -48,18 +49,26 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadDumpCache() -> DumpCache? {
|
static func loadDumpCache() -> DumpCache? {
|
||||||
|
if let raw = FuelStore.loadAppGroupFile(liveDumpCacheFileName),
|
||||||
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) {
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate the legacy UserDefaults cache when present.
|
||||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
let raw = defaults.data(forKey: liveDumpCacheKey),
|
let raw = defaults.data(forKey: liveDumpCacheKey),
|
||||||
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) {
|
||||||
|
defaults.removeObject(forKey: liveDumpCacheKey)
|
||||||
|
}
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveDumpCache(day: String, data: Data) {
|
static func saveDumpCache(day: String, data: Data) {
|
||||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
guard let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
||||||
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
_ = FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName)
|
||||||
defaults.set(raw, forKey: liveDumpCacheKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the cached dump is already the freshest the mirror has —
|
/// True when the cached dump is already the freshest the mirror has —
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ cd "$(dirname "$0")/.."
|
|||||||
PROJECT="$PWD/FuelBoard.xcodeproj"
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
SCHEME="FuelBoard"
|
SCHEME="FuelBoard"
|
||||||
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
||||||
SERVE_DIR="/tmp/ipa-serve"
|
SERVE_DIR="$PWD/Builds"
|
||||||
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
||||||
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
||||||
HOST="192.168.1.131"
|
HOST="192.168.1.131"
|
||||||
PORT="8765"
|
PORT="8765"
|
||||||
|
|
||||||
|
export SERVE_DIR
|
||||||
|
|
||||||
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
||||||
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
||||||
@@ -46,7 +48,9 @@ import os, shutil, zipfile
|
|||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
stage = '/tmp/fuelboard-ipa-stage-full'
|
stage = '/tmp/fuelboard-ipa-stage-full'
|
||||||
payload = os.path.join(stage, 'Payload')
|
payload = os.path.join(stage, 'Payload')
|
||||||
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
|
serve = os.environ['SERVE_DIR']
|
||||||
|
out = os.path.join(serve, 'FuelBoard-watch.ipa')
|
||||||
|
os.makedirs(serve, exist_ok=True)
|
||||||
if os.path.exists(stage):
|
if os.path.exists(stage):
|
||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
os.makedirs(payload, exist_ok=True)
|
os.makedirs(payload, exist_ok=True)
|
||||||
@@ -73,7 +77,8 @@ import os, shutil, zipfile
|
|||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
||||||
payload = os.path.join(stage, 'Payload')
|
payload = os.path.join(stage, 'Payload')
|
||||||
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
|
serve = os.environ['SERVE_DIR']
|
||||||
|
out = os.path.join(serve, 'FuelBoard-sideload.ipa')
|
||||||
if os.path.exists(stage):
|
if os.path.exists(stage):
|
||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
os.makedirs(payload, exist_ok=True)
|
os.makedirs(payload, exist_ok=True)
|
||||||
@@ -117,6 +122,7 @@ for name, path in paths.items():
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
echo "==> URLs"
|
echo "==> URLs"
|
||||||
|
echo "Serving: ipa-serve # in $(basename "$PWD"), or: ipa-serve fuelboard"
|
||||||
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
||||||
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
||||||
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
||||||
|
|||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install.sh — build FuelBoard dev-signed and install straight to a paired iPhone.
|
||||||
|
#
|
||||||
|
# Streamlined flow (single command):
|
||||||
|
# ./scripts/install.sh # build + install + launch on the first paired iPhone
|
||||||
|
# ./scripts/install.sh --device UDID # target a specific device (xcrun devicectl list devices)
|
||||||
|
# ./scripts/install.sh --no-launch # install but don't auto-launch
|
||||||
|
#
|
||||||
|
# Why this exists: sideloading (SideStore/AltStore) got replaced on 2026-09-25 by a
|
||||||
|
# proper dev-signed install via devicectl — the phone is paired, so no LAN server,
|
||||||
|
# no delete/reinstall dance, and the installed app is a real signed build. The
|
||||||
|
# Release config targets the device (not the simulator) and reuses Xcode automatic
|
||||||
|
# signing with the project's DEVELOPMENT_TEAM.
|
||||||
|
#
|
||||||
|
# Requires: a paired + unlocked iPhone (xcrun devicectl list devices), and the
|
||||||
|
# Apple Development identity in the keychain.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
|
SCHEME="FuelBoard"
|
||||||
|
DERIVED_DATA="/tmp/fuelboard-devinstall"
|
||||||
|
APP="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
|
DEVICE=""
|
||||||
|
DEVICE_NAME="(first paired iPhone)"
|
||||||
|
LAUNCH=1
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--device) DEVICE="$2"; shift 2 ;;
|
||||||
|
--no-launch) LAUNCH=0; shift ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Resolve the device if not given: the first connected physical iPhone.
|
||||||
|
# devicectl --device wants the CoreDevice Identifier (field 3 of `list devices`,
|
||||||
|
# e.g. C519BE18-...). NOTE: xcodebuild -destination wants the hardware UDID
|
||||||
|
# (00008130-...), so we build with the generic platform destination and only use
|
||||||
|
# the CoreDevice Identifier for devicectl install/launch.
|
||||||
|
if [[ -z "$DEVICE" ]]; then
|
||||||
|
DEVICE="$(xcrun devicectl list devices 2>/dev/null | awk '/(available \(paired\))/ && /iPhone/ {print $3; exit}')"
|
||||||
|
if [[ -z "$DEVICE" ]]; then
|
||||||
|
echo "install.sh: no paired iPhone found. Check: xcrun devicectl list devices" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Build (Release, dev-signed, generic iOS destination)"
|
||||||
|
rm -rf "$DERIVED_DATA"
|
||||||
|
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
build 2>&1 | grep -E "error:|BUILD (SUCCEEDED|FAILED)|Signing Identity" | tail -8
|
||||||
|
|
||||||
|
echo "==> Verify bundle"
|
||||||
|
test -d "$APP" || { echo "build failed: no app at $APP" >&2; exit 1; }
|
||||||
|
echo " bundle: $(plutil -extract CFBundleIdentifier raw "$APP/Info.plist")" \
|
||||||
|
" $(plutil -extract CFBundleShortVersionString raw "$APP/Info.plist")" \
|
||||||
|
"($(plutil -extract CFBundleVersion raw "$APP/Info.plist"))"
|
||||||
|
|
||||||
|
echo "==> Install via devicectl"
|
||||||
|
xcrun devicectl device install app --device "$DEVICE" "$APP" 2>&1 | tail -6
|
||||||
|
|
||||||
|
if [[ "$LAUNCH" -eq 1 ]]; then
|
||||||
|
BID="$(plutil -extract CFBundleIdentifier raw "$APP/Info.plist")"
|
||||||
|
echo "==> Launch"
|
||||||
|
xcrun devicectl device process launch --device "$DEVICE" "$BID" 2>&1 | tail -4
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done ✅ (delete the old icon on the phone if you want it refreshed)"
|
||||||
Reference in New Issue
Block a user