Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f53b9290 | ||
|
|
2921271be7 | ||
|
|
7cb23e09c2 | ||
|
|
7dbd48786f | ||
|
|
3a1ef0bd59 | ||
|
|
551a40ddf0 |
+3
-1
@@ -31,7 +31,9 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
provisioning profile (or Xcode automatic signing once the account is in
|
||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||
collects location. A one-page GitHub Pages privacy policy is fine.
|
||||
collects location. Policy text is now in `PRIVACY.md`; publish it via the
|
||||
FuelBoard marketing site or GitHub Pages and paste that public URL into App
|
||||
Store Connect.
|
||||
|
||||
## Required tasks — owner [ME] (technical, do after account exists)
|
||||
|
||||
|
||||
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>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -37,17 +37,6 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -59,6 +48,17 @@
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -165,9 +165,9 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||
buildPhases = (
|
||||
ADCE5B4B3033A329008C131C /* Sources */,
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||
@@ -405,8 +405,9 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -418,14 +419,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -471,8 +471,9 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -484,14 +485,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -537,7 +537,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -549,13 +549,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -602,7 +602,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -614,13 +614,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -699,7 +699,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -707,7 +707,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -722,7 +722,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -730,7 +730,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -745,7 +745,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -754,7 +754,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -770,7 +770,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -779,7 +779,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -792,7 +792,7 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ADCE5B573033A32B008C131C /* Debug */,
|
||||
|
||||
+23
-10
@@ -981,6 +981,21 @@ struct StationRow: View {
|
||||
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 {
|
||||
HStack(spacing: 12) {
|
||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||
@@ -1017,6 +1032,12 @@ struct StationRow: View {
|
||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||
.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)")
|
||||
.font(.caption)
|
||||
@@ -1059,16 +1080,8 @@ struct StationRow: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
switch tapAction {
|
||||
case .openMap:
|
||||
if let url = station.mapsDirectionsURL {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
case .showDetails:
|
||||
onShowDetails(station)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: handleTap)
|
||||
.onLongPressGesture(perform: showDetails)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -631,19 +631,18 @@ final class TipStore: ObservableObject {
|
||||
let id: String // product ID
|
||||
let name: String
|
||||
let blurb: String
|
||||
let fallbackPrice: String
|
||||
let accent: Color // card/price accent (matches the fuel palette)
|
||||
}
|
||||
|
||||
static let tiers: [TipTier] = [
|
||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
||||
blurb: "Just enough to keep things moving.",
|
||||
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
||||
blurb: "A generous top-up for development.",
|
||||
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
||||
blurb: "Keeping the app on the road.",
|
||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||
]
|
||||
|
||||
@@ -667,9 +666,19 @@ final class TipStore: ObservableObject {
|
||||
/// the queue but are never finished or acknowledged.
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
|
||||
/// Uses the existing hidden developer switch in Settings. StoreKit
|
||||
/// diagnostics must stay silent unless the developer explicitly enables
|
||||
/// Debug mode with the five-tap Version gesture.
|
||||
private static func debugLog(_ message: @autoclosure () -> String) {
|
||||
guard FuelStore.loadDebugMode() else { return }
|
||||
print(message())
|
||||
}
|
||||
|
||||
init() {
|
||||
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
|
||||
updatesTask = Task { [weak self] in
|
||||
for await update in Transaction.updates {
|
||||
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
|
||||
await self?.handle(update)
|
||||
}
|
||||
}
|
||||
@@ -681,10 +690,18 @@ final class TipStore: ObservableObject {
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
|
||||
// Never deliver or finish an untrusted purchase.
|
||||
guard case .verified(let transaction) = update else { return }
|
||||
guard case .verified(let transaction) = update else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
|
||||
return
|
||||
}
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Ignoring unrelated product ID=\(transaction.productID)")
|
||||
return
|
||||
}
|
||||
Self.debugLog("[FuelBoard StoreKit] Finishing verified transaction productID=\(transaction.productID)")
|
||||
await transaction.finish()
|
||||
showOutcome(
|
||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||
@@ -713,45 +730,84 @@ final class TipStore: ObservableObject {
|
||||
}
|
||||
|
||||
func displayPrice(for tier: TipTier) -> String {
|
||||
products[tier.id]?.displayPrice ?? tier.fallbackPrice
|
||||
products[tier.id]?.displayPrice
|
||||
?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit")
|
||||
}
|
||||
|
||||
func load() async {
|
||||
// Refreshes product state on every visit so newly-approved products
|
||||
// (or restored transactions) are picked up.
|
||||
let ids = Self.tiers.map(\.id)
|
||||
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
|
||||
do {
|
||||
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
||||
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
let fetched = try await Product.products(for: ids)
|
||||
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)")
|
||||
}
|
||||
merge(fetched)
|
||||
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
|
||||
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
|
||||
} catch {
|
||||
// No products yet (sideloaded build) — the buttons still show the
|
||||
// intended prices and report purchase attempts gracefully.
|
||||
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||
// Keep the cards visible so the user can retry while App Store
|
||||
// Connect metadata propagates; displayPrice() shows "Unavailable"
|
||||
// until StoreKit returns a real product.
|
||||
products = [:]
|
||||
}
|
||||
}
|
||||
|
||||
private func merge(_ fetched: [Product]) {
|
||||
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
|
||||
}
|
||||
|
||||
private func product(for tier: TipTier) async throws -> Product? {
|
||||
if let product = products[tier.id] {
|
||||
Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)")
|
||||
return product
|
||||
}
|
||||
|
||||
// TestFlight/App Store Connect product availability can lag the Settings
|
||||
// onAppear load. Re-query the tapped tier before falling back so a newly
|
||||
// approved IAP opens the system purchase sheet instead of showing stale
|
||||
// "not available" copy.
|
||||
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
|
||||
let fetched = try await Product.products(for: [tier.id])
|
||||
Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
||||
merge(fetched)
|
||||
let match = fetched.first { $0.id == tier.id }
|
||||
Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)")
|
||||
return match
|
||||
}
|
||||
|
||||
func purchase(_ tier: TipTier) async {
|
||||
guard !purchaseInProgress else { return }
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase requested ID=\(tier.id) name=\(tier.name)")
|
||||
guard !purchaseInProgress else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase ignored because another purchase is in progress")
|
||||
return
|
||||
}
|
||||
purchaseInProgress = true
|
||||
defer { purchaseInProgress = false }
|
||||
|
||||
// If the product hasn't loaded (e.g. not configured in App Store
|
||||
// Connect yet), still allow the attempt so the user sees a clear
|
||||
// outcome rather than a dead button.
|
||||
guard let product = products[tier.id] else {
|
||||
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 {
|
||||
guard let product = try await product(for: tier) else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
|
||||
showOutcome(
|
||||
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
|
||||
icon: "info.circle.fill",
|
||||
tint: Color(.secondaryLabel)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
|
||||
switch verification {
|
||||
case .verified(let transaction):
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
|
||||
// Consume the consumable — otherwise StoreKit re-delivers
|
||||
// it through Transaction.updates on every launch.
|
||||
await transaction.finish()
|
||||
@@ -761,6 +817,7 @@ final class TipStore: ObservableObject {
|
||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||
)
|
||||
case .unverified:
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
|
||||
showOutcome(
|
||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
@@ -768,17 +825,21 @@ final class TipStore: ObservableObject {
|
||||
)
|
||||
}
|
||||
case .userCancelled:
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
case .pending:
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
|
||||
showOutcome(
|
||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||
icon: "hourglass",
|
||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||
)
|
||||
@unknown default:
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
|
||||
dismissOutcome()
|
||||
}
|
||||
} catch {
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||
showOutcome(
|
||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||
icon: "xmark.circle.fill",
|
||||
|
||||
@@ -45,6 +45,14 @@ struct StationDetailView: View {
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@
|
||||
"Done" = "Done";
|
||||
"Directions" = "Directions";
|
||||
"Report issue" = "Report issue";
|
||||
"Membership required" = "Membership required";
|
||||
"Report an error?" = "Report an error?";
|
||||
"Cancel" = "Cancel";
|
||||
"Report" = "Report";
|
||||
|
||||
+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.
|
||||
Reference in New Issue
Block a user