Rewrite the Settings screen in short British English and remove the duplicated paragraphs, keeping every control, binding and preference key as it was. - Fuel Savings: one-line descriptions for Best Value, average fuel economy, fill-up amount (worded for the unit in use) and minimum saving; one concise general explanation replaces the long footer; the orange "New comparison basis" notice is removed. - Units: "Station sign" becomes "Pence per litre"; the combined paragraph becomes two contextual descriptions. - Distances: "Accurate distances" becomes "Driving distances" with a short description; the route-request and search-radii paragraph is removed. - Tap action: "Open map"/"More info" become "Directions"/"Station details" with a short description. - Data checking: Standard and Smart summaries cut to one line each, one shared background-update note, duplicated footer removed. - Replay onboarding: shorter description. Support FuelBoard untouched. Copy and layout only: no calculations, ranking, refresh schedules, stored values or persistence changed. New and reworded literals live in FuelBoard/en.lproj/Localizable.strings.
1201 lines
60 KiB
Swift
1201 lines
60 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
import UserNotifications
|
|
import WidgetKit
|
|
|
|
/// Settings tab — distance units, onboarding replay, a tip jar, and an About
|
|
/// section. Test-notification buttons live in a Debug section that is hidden
|
|
/// unless the developer debug flag is on (tap the About → Version row five
|
|
/// times to toggle it — no user-facing switch). The first test button uses
|
|
/// REAL data (the actual cheapest station for the monitored fuel within the
|
|
/// radius, exactly like a live alert) via `onTestAlert`; the second fires
|
|
/// with no criteria at all. The Data source section (connection/stations/
|
|
/// freshness) is debug-only too — moved here so release builds don't expose
|
|
/// relay plumbing.
|
|
struct SettingsView: View {
|
|
/// Shared tip-purchase store — owned by ContentView so its outcome can
|
|
/// ride the app-wide status banner (above the tabs), not a local card.
|
|
@ObservedObject var tipStore: TipStore
|
|
@Binding var distanceUnit: DistanceUnit
|
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
|
@Binding var stationTapAction: StationTapAction
|
|
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
|
/// The Best Value gate — the net saving that makes a station worth the trip.
|
|
/// Edited here, with the fill size and MPG that PRODUCE that saving, rather
|
|
/// than in the Alerts tab: the same figure also decides which station rows
|
|
/// carry the BEST VALUE badge, so it is a fuel-savings setting first and an
|
|
/// alert setting second. Stored under the alerts key either way.
|
|
@Binding var bestValueThresholdGBP: Double
|
|
@Binding var dataRefreshMode: DataRefreshMode
|
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
|
/// tab) so the test notification matches what real alerts will say.
|
|
var alertsFuel: FuelType = .e10
|
|
var alertsRadiusKM: Double = 3.0
|
|
/// Result line from the last real-data test alert (what it picked).
|
|
var testAlertResult: String?
|
|
/// Drives the real-data test — ContentView routes this to the live
|
|
/// ProximityMonitor so the test uses its actual stations/fuel/radius.
|
|
var onTestAlert: () -> Void = {}
|
|
/// Drives the plain test (no criteria) — routed to the live monitor so it
|
|
/// can carry a real station's name, price and coordinates.
|
|
var onPlainTestAlert: () -> Void = {}
|
|
/// Recomputes the debug location snapshot (coordinates + in-range count).
|
|
var onRefreshDebugStatus: () -> Void = {}
|
|
var onShowOnboarding: () -> Void = {}
|
|
/// Live snapshot of the device fix + stations in range (from the monitor).
|
|
var debugStatus: DebugLocationStatus?
|
|
/// The app's own TOP-badge cheapest (selected fuel + stationLimit radius),
|
|
/// passed in so the Debug section mirrors the app list exactly.
|
|
var appCheapest: DebugAppCheapest?
|
|
/// Last CoreLocation region-monitoring failure (monitor.lastRegionError).
|
|
/// Shown in Debug so a silently-failed geofence registration is visible.
|
|
var regionError: String?
|
|
/// Live geofence count (monitor.monitoredStationIDs.count) — Debug-only
|
|
/// status; the user-facing Alerts tab no longer exposes fence plumbing.
|
|
var monitoredCount: Int = 0
|
|
/// Live alert trace (monitor.alertLog) — every stage the alert chain
|
|
/// reached this session (region event / gate / scheduled). Debug-only.
|
|
var alertLog: [AlertLogEntry] = []
|
|
/// Region events received this session (monitor.regionEventCount).
|
|
var regionEventCount: Int = 0
|
|
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
|
|
var debugFenceIdentifier: String?
|
|
var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle
|
|
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
|
|
var onDebugFence: () -> Void = {}
|
|
/// Clears the armed fence (monitor.clearDebugFence).
|
|
var onClearDebugFence: () -> Void = {}
|
|
|
|
@State private var testAlertMessage: String?
|
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
|
/// "Driving distances" — read once on appear and written straight through to
|
|
/// the shared store, which is what every display surface actually consults.
|
|
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
|
/// Slider drafts — the value under the thumb while it is down. `nil` means
|
|
/// "not dragging", in which case the stored setting is shown, so an external
|
|
/// change (a unit switch, the in-list control, a QA hook) is picked up with
|
|
/// no re-seeding. Dragging writes only the draft; the setting is saved once,
|
|
/// when the drag ends — see `commitMPG` / `commitFillUp`.
|
|
@State private var mpgDraft: Double?
|
|
@State private var fillUpDraft: Double?
|
|
@State private var versionTapCount = 0
|
|
@State private var lastVersionTap = Date.distantPast
|
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
|
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
|
@State private var smallWidgetDiag: String?
|
|
@State private var mediumWidgetDiag: String?
|
|
@State private var installedWidgets: String = ""
|
|
|
|
/// Which numeric field on this screen is being edited. `.decimalPad` has no
|
|
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
|
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
|
/// nothing sets it — it stays for any numeric field this screen takes again.
|
|
@FocusState private var focusedSavingsField: SavingsField?
|
|
|
|
private enum SavingsField: Hashable {
|
|
case averageMPG
|
|
case fillAmount
|
|
}
|
|
|
|
private func refreshWidgetDiag() {
|
|
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
|
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
|
WidgetCenter.shared.getCurrentConfigurations { result in
|
|
let text: String
|
|
switch result {
|
|
case .success(let widgets):
|
|
text = widgets.isEmpty
|
|
? "No widgets installed"
|
|
: widgets.map { "\($0.kind) · \($0.family)" }.joined(separator: "\n")
|
|
case .failure(let error):
|
|
text = "Error: \(error.localizedDescription)"
|
|
}
|
|
Task { @MainActor in
|
|
installedWidgets = text
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
|
Text("Find stations that could save you money, even after the cost of driving there.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
// The feature's own controls follow its toggle: switched
|
|
// off they grey out but stay on screen (same rule as the
|
|
// distance picker in Closest mode) and keep their values, so
|
|
// re-enabling restores exactly what was there. The sections
|
|
// that USE the feature — the in-list fill-up control and the
|
|
// Best Value sort option — are gated on `isConfigured`,
|
|
// which already includes `enabled`, so they hide entirely.
|
|
Group {
|
|
HStack {
|
|
Text("Average fuel economy")
|
|
Spacer()
|
|
Text(mpgText)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
// Draft-bound: the thumb moves a local value and the
|
|
// setting is written once, when the drag ends. Writing
|
|
// durable app-wide state on every step is what made this
|
|
// slider stutter.
|
|
Slider(value: mpgSliderValue,
|
|
in: FuelSavingsCalculator.mpgSliderRange,
|
|
step: FuelSavingsCalculator.mpgSliderStep) { editing in
|
|
if !editing { commitMPG() }
|
|
}
|
|
.accessibilityLabel("Average fuel economy")
|
|
.accessibilityValue(mpgText)
|
|
Text("How many miles your car travels per gallon.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
|
Text(unit.displayName).tag(unit)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
HStack {
|
|
Text("Fill-up amount")
|
|
Spacer()
|
|
Text(fillUpText)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Slider(value: fillUpSliderValue,
|
|
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
|
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in
|
|
if !editing { commitFillUp() }
|
|
}
|
|
.accessibilityLabel("Fill-up amount")
|
|
.accessibilityValue(fillUpText)
|
|
Text(fillUpAmountDescription)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
|
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
|
Text(FuelSavingsFormat.money(value)).tag(value)
|
|
}
|
|
}
|
|
// Persisted by ContentView's single
|
|
// `.onChange(of: alertsBestValueThresholdGBP)`, which
|
|
// also refreshes the alert monitor — so this picker
|
|
// needs no handler of its own. The store snaps to the
|
|
// nearest option, so the picker and the stored value
|
|
// cannot disagree.
|
|
Text("The smallest saving you'd consider worth the extra drive.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
|
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
.disabled(!fuelSavingsSettings.enabled)
|
|
} header: {
|
|
Text("Fuel Savings")
|
|
} footer: {
|
|
Text("FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.")
|
|
}
|
|
|
|
Section {
|
|
Picker("Distance", selection: $distanceUnit) {
|
|
ForEach(DistanceUnit.allCases) { unit in
|
|
Text(unit.displayName).tag(unit)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: distanceUnit) { _, newValue in
|
|
FuelStore.saveDistanceUnit(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
Text("Choose how distances are displayed throughout FuelBoard.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Picker("Price display", selection: $priceDisplayStyle) {
|
|
ForEach(PriceDisplayStyle.allCases) { style in
|
|
Text(style.displayName).tag(style)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: priceDisplayStyle) { _, newValue in
|
|
FuelStore.savePriceDisplayStyle(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
Text("Show prices as 129.9p or £1.299 per litre.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("Units")
|
|
}
|
|
|
|
Section {
|
|
Toggle("Driving distances", isOn: $accurateDistances)
|
|
.onChange(of: accurateDistances) { _, newValue in
|
|
FuelStore.setAccurateDistancesEnabled(newValue)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
|
object: nil)
|
|
}
|
|
Text("Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("Distances")
|
|
}
|
|
|
|
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("Choose what happens when you tap a station: get directions or view its details.")
|
|
}
|
|
|
|
Section {
|
|
Picker("Data checking", selection: $dataRefreshMode) {
|
|
ForEach(DataRefreshMode.allCases) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: dataRefreshMode) { _, newValue in
|
|
FuelStore.saveDataRefreshMode(newValue)
|
|
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
|
WatchSyncManager.shared.pushSnapshot()
|
|
}
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(dataRefreshMode.summary)
|
|
Text("Background updates depend on when your iPhone allows them.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.footnote)
|
|
} header: {
|
|
Text("Data checking")
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
onShowOnboarding()
|
|
} label: {
|
|
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
|
}
|
|
} footer: {
|
|
Text("View the welcome screens and set up permissions again.")
|
|
}
|
|
|
|
if debugMode {
|
|
Section {
|
|
Button {
|
|
sendTestAlert()
|
|
} label: {
|
|
Label("Test alert notification (real data)", systemImage: "bell.badge.fill")
|
|
}
|
|
if let testAlertMessage {
|
|
Text(testAlertMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let testAlertResult {
|
|
Label(testAlertResult, systemImage: "checkmark.circle.fill")
|
|
.font(.footnote)
|
|
.foregroundStyle(.green)
|
|
}
|
|
if let regionError {
|
|
Label(regionError, systemImage: "exclamationmark.triangle.fill")
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
Button {
|
|
onPlainTestAlert()
|
|
} label: {
|
|
Label("Send plain test notification", systemImage: "bell.slash.fill")
|
|
}
|
|
debugLocationRows
|
|
} header: {
|
|
Text("Debug")
|
|
} footer: {
|
|
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
|
}
|
|
.onAppear { onRefreshDebugStatus() }
|
|
|
|
Section {
|
|
Button {
|
|
refreshWidgetDiag()
|
|
} label: {
|
|
Label("Refresh widget diagnostics", systemImage: "arrow.clockwise")
|
|
}
|
|
if let smallWidgetDiag {
|
|
Text("SMALL: \(smallWidgetDiag)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
} else {
|
|
Text("SMALL: no beacon yet")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let mediumWidgetDiag {
|
|
Text("MEDIUM: \(mediumWidgetDiag)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
} else {
|
|
Text("MEDIUM: no beacon yet")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(installedWidgets.isEmpty ? "No widgets installed" : installedWidgets)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("Widget Diagnostics")
|
|
} footer: {
|
|
Text("Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.")
|
|
}
|
|
.onAppear { refreshWidgetDiag() }
|
|
|
|
Section {
|
|
Button {
|
|
Task { await tipStore.load() }
|
|
} label: {
|
|
Label("Probe App Store products", systemImage: "arrow.clockwise")
|
|
}
|
|
LabeledContent("Probe", value: tipStore.productLoadStatus)
|
|
if let loadedAt = tipStore.lastProductLoadDate {
|
|
LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard))
|
|
}
|
|
LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs")
|
|
LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products")
|
|
if !tipStore.debugProducts.isEmpty {
|
|
ForEach(tipStore.debugProducts) { product in
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(product.id)
|
|
.font(.caption.monospaced())
|
|
.textSelection(.enabled)
|
|
Text(product.displayName)
|
|
.font(.footnote.weight(.medium))
|
|
Text("\(product.type) · \(product.displayPrice) · raw \(product.price)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
} else {
|
|
Text("No products were returned by StoreKit.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if !tipStore.missingProductIDs.isEmpty {
|
|
Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
.textSelection(.enabled)
|
|
}
|
|
if let error = tipStore.lastProductLoadError {
|
|
Text("Error: \(error)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
.textSelection(.enabled)
|
|
}
|
|
} header: {
|
|
Text("IAP Diagnostics")
|
|
} footer: {
|
|
Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.")
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text("Connection")
|
|
Spacer()
|
|
Text(connectionText)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
HStack {
|
|
Text("Stations")
|
|
Spacer()
|
|
Text(stationCountText)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
HStack {
|
|
Text("Data updated")
|
|
Spacer()
|
|
Text(dataUpdatedText)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
} header: {
|
|
Text("Data source")
|
|
} footer: {
|
|
Text("Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.")
|
|
}
|
|
|
|
Section {
|
|
LabeledContent("Status", value: roadDistanceDebugStatus.summary)
|
|
LabeledContent("Updated", value: roadDistanceDebugStatus.updatedAt.formatted(date: .omitted, time: .standard))
|
|
if let lat = roadDistanceDebugStatus.originLat,
|
|
let lng = roadDistanceDebugStatus.originLng {
|
|
LabeledContent("Origin", value: String(format: "%.5f, %.5f", lat, lng))
|
|
}
|
|
LabeledContent("Pool", value: "\(roadDistanceDebugStatus.stationPoolCount)")
|
|
LabeledContent("Candidates", value: "\(roadDistanceDebugStatus.candidateCount)")
|
|
LabeledContent("Cached", value: "\(roadDistanceDebugStatus.cachedCount)")
|
|
if roadDistanceDebugStatus.routedCount > 0 || roadDistanceDebugStatus.failedCount > 0 {
|
|
LabeledContent("Routed", value: "\(roadDistanceDebugStatus.routedCount)")
|
|
LabeledContent("Failed", value: "\(roadDistanceDebugStatus.failedCount)")
|
|
}
|
|
if let elapsed = roadDistanceDebugStatus.elapsedSeconds {
|
|
LabeledContent("Elapsed", value: String(format: "%.1fs", elapsed))
|
|
}
|
|
if let moved = roadDistanceDebugStatus.movedMeters {
|
|
LabeledContent("Moved", value: String(format: "%.0f m", moved))
|
|
}
|
|
if let remaining = roadDistanceDebugStatus.throttleSecondsRemaining {
|
|
LabeledContent("Throttle", value: String(format: "%.0f s remaining", max(0, remaining)))
|
|
}
|
|
LabeledContent("Ready", value: roadDistanceDebugStatus.complete ? "yes" : "no")
|
|
} header: {
|
|
Text("Road distance fill")
|
|
} footer: {
|
|
Text("Shows whether Driving distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
|
}
|
|
|
|
Section {
|
|
if monitoredCount == 0 {
|
|
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
LabeledContent("Geofenced stations", value: "\(monitoredCount)")
|
|
}
|
|
Text("Favourites for \(alertsFuel.displayName.lowercased()) get priority, then the closest stations selling that fuel fill the rest (18 max, iOS region limit). Each station alerts at most once per hour.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} header: {
|
|
Text("Geofence monitoring")
|
|
}
|
|
|
|
Section {
|
|
if debugFenceIdentifier != nil {
|
|
LabeledContent("Debug fence", value: "armed (100 m)")
|
|
Text("Step 100 m away from your current position, then walk back in. 'Region events this session' should tick and a plain notification fires — proof iOS delivers region events on this install, with no alert gates involved.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button("Clear debug fence", role: .destructive) {
|
|
onClearDebugFence()
|
|
}
|
|
} else {
|
|
Button("Register 100 m fence at current location") {
|
|
onDebugFence()
|
|
}
|
|
Text("A debug-only circle around your current position that proves geofence delivery end-to-end: step out 100 m and back in, and watch 'Region events this session' tick.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
Text("Debug fence (delivery test)")
|
|
}
|
|
|
|
Section {
|
|
LabeledContent("Region events this session", value: "\(regionEventCount)")
|
|
if alertLog.isEmpty {
|
|
Text("No alert activity yet — drive across a geofence boundary, or tap the test-alert button above to exercise the chain without a geofence.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(alertLog) { entry in
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Image(systemName: alertSymbol(for: entry.kind))
|
|
.foregroundStyle(alertColor(for: entry.kind))
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(entry.text)
|
|
.font(.caption2)
|
|
.textSelection(.enabled)
|
|
Text(entry.date, format: .dateTime.hour().minute().second())
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Alert trace")
|
|
} footer: {
|
|
Text("One line per stage the live chain reached: a geofence entry, each gate that passed or blocked the alert (dedup, cheapest-within-radius, fresh fetch), then the scheduled alert. If the trace shows 'alert scheduled' but no banner appears, the failure is in notification delivery itself (permission or background delivery), not the alert logic.")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
VStack(spacing: 10) {
|
|
ForEach(TipStore.tiers) { tier in
|
|
Button {
|
|
Task { await tipStore.purchase(tier) }
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "fuelpump.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(tier.accent)
|
|
.frame(width: 34)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(tier.name)
|
|
.font(.headline)
|
|
Text(tier.blurb)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Text(tipStore.displayPrice(for: tier))
|
|
.font(.headline.weight(.bold))
|
|
.foregroundStyle(tier.accent)
|
|
.monospacedDigit()
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemGroupedBackground))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(tier.accent.opacity(0.05))
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(tipStore.purchaseInProgress)
|
|
}
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
|
} header: {
|
|
Text("Support FuelBoard")
|
|
} footer: {
|
|
Text("A tip helps keep this app free and supports development. Thank you")
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text("Version")
|
|
Spacer()
|
|
Text(appVersion)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { handleVersionTap() }
|
|
}
|
|
} header: {
|
|
Text("About")
|
|
}
|
|
|
|
}
|
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
|
.keyboardDoneButton { focusedSavingsField = nil }
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.navigationTitle("Settings")
|
|
.onAppear {
|
|
Task { await tipStore.load() }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Debug location block: device coordinates + fix age, then the in-range
|
|
/// indicator. "Cheapest in range" mirrors the app list (selected fuel +
|
|
/// stationLimit radius) via `appCheapest`; the "In range" count mirrors
|
|
/// the alert prediction (alert fuel + alert radius).
|
|
@ViewBuilder
|
|
private var debugLocationRows: some View {
|
|
Divider()
|
|
if let status = debugStatus {
|
|
HStack {
|
|
Label("Device", systemImage: "location.fill")
|
|
Spacer()
|
|
if let coord = status.coordinate {
|
|
Text(String(format: "%.5f, %.5f", coord.lat, coord.lng))
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("No fix")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let age = status.fixAge {
|
|
HStack {
|
|
Label("Fix age", systemImage: "clock.fill")
|
|
Spacer()
|
|
Text(ageText(age))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
HStack {
|
|
Label("In range", systemImage: "location.circle.fill")
|
|
Spacer()
|
|
HStack(spacing: 6) {
|
|
if status.coordinate != nil {
|
|
Circle()
|
|
.fill(inRangeColor(status.inRangeCount))
|
|
.frame(width: 10, height: 10)
|
|
} else {
|
|
Circle()
|
|
.fill(.gray)
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
Text(inRangeText(status))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let cheapest = appCheapest {
|
|
HStack {
|
|
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
|
Spacer()
|
|
Text(cheapestText(cheapest.station, fuel: cheapest.fuel, distance: cheapest.distanceKM))
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if let cheapest = status.cheapestInRange {
|
|
// Fallback when the app view hasn't resolved yet — show the
|
|
// alert-prediction cheapest rather than nothing.
|
|
HStack {
|
|
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
|
Spacer()
|
|
Text(cheapestText(cheapest, fuel: status.fuel, distance: status.cheapestDistanceKM))
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else {
|
|
HStack {
|
|
Label("Device", systemImage: "location.fill")
|
|
Spacer()
|
|
Text("—")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func inRangeColor(_ count: Int) -> Color {
|
|
count > 0 ? .green : .red
|
|
}
|
|
|
|
private func inRangeText(_ status: DebugLocationStatus) -> String {
|
|
guard status.coordinate != nil else { return NSLocalizedString("waiting for fix", comment: "") }
|
|
let fuelName = status.fuel.displayName.lowercased()
|
|
if status.inRangeCount == 1 {
|
|
return String(format: NSLocalizedString("1 %@ station within %@", comment: ""), fuelName, distanceUnit.format(status.radiusKM))
|
|
}
|
|
return String(format: NSLocalizedString("%lld %@ stations within %@", comment: ""), status.inRangeCount, fuelName, distanceUnit.format(status.radiusKM))
|
|
}
|
|
|
|
private func cheapestText(_ station: FuelStation, fuel: FuelType, distance: Double?) -> String {
|
|
let price = station.prices[fuel].map { String(format: "%.1fp", $0) } ?? "—"
|
|
if let distance {
|
|
return "\(station.name) · \(distanceUnit.format(distance)) · \(price)"
|
|
}
|
|
return "\(station.name) · \(price)"
|
|
}
|
|
|
|
private func ageText(_ age: TimeInterval) -> String {
|
|
if age < 60 { return String(format: NSLocalizedString("%llds ago", comment: ""), Int(age)) }
|
|
if age < 3600 { return String(format: NSLocalizedString("%lldm %llds ago", comment: ""), Int(age / 60), Int(age.truncatingRemainder(dividingBy: 60))) }
|
|
return String(format: NSLocalizedString("%lldh %lldm ago", comment: ""), Int(age / 3600), Int((age.truncatingRemainder(dividingBy: 3600)) / 60))
|
|
}
|
|
|
|
private var appVersion: String {
|
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
|
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
|
return "\(version) (\(build))"
|
|
}
|
|
|
|
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { fuelSavingsSettings.enabled },
|
|
set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) }
|
|
)
|
|
}
|
|
|
|
/// Slider values, draft-first: while the thumb is down the draft is what the
|
|
/// control and its read-out show; with no drag in progress the store's value
|
|
/// shows, so any external change (unit switch, in-list control, QA hook) is
|
|
/// reflected without re-seeding anything.
|
|
private var mpgSliderValue: Binding<Double> {
|
|
Binding(
|
|
get: { mpgDraft ?? fuelSavingsSettings.mpg },
|
|
set: { mpgDraft = $0 }
|
|
)
|
|
}
|
|
|
|
private var fillUpSliderValue: Binding<Double> {
|
|
Binding(
|
|
get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
|
|
set: { fillUpDraft = $0 }
|
|
)
|
|
}
|
|
|
|
/// The drag has ended: write the value once, and only if it actually moved.
|
|
/// The draft is cleared either way, so the control falls back to the stored
|
|
/// setting and shows what was saved — the clamps can lower it.
|
|
private func commitMPG() {
|
|
defer { mpgDraft = nil }
|
|
guard let draft = mpgDraft else { return }
|
|
let mpg = min(max(draft, 0), FuelSavingsCalculator.maximumMPG)
|
|
guard mpg != fuelSavingsSettings.mpg else { return }
|
|
fuelSavingsSettings.mpg = mpg
|
|
saveFuelSavingsField(.mpg)
|
|
}
|
|
|
|
private func commitFillUp() {
|
|
defer { fillUpDraft = nil }
|
|
guard let draft = fillUpDraft else { return }
|
|
let amount = min(max(draft, 0),
|
|
FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
|
guard amount != fuelSavingsSettings.preferredAmount.value else { return }
|
|
fuelSavingsSettings.preferredAmount.value = amount
|
|
saveFuelSavingsField(.amount)
|
|
}
|
|
|
|
/// Persists the one field that changed. Saving the whole settings object
|
|
/// rewrites all four keys, which is only right when more than one has moved.
|
|
private func saveFuelSavingsField(_ field: FuelStore.FuelSavingsField) {
|
|
FuelStore.saveFuelSavingsField(field, from: fuelSavingsSettings)
|
|
}
|
|
|
|
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
|
Binding(
|
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
|
set: { newUnit in
|
|
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
|
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
|
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
|
// The unit and the amount move together, so both keys are written.
|
|
saveFuelSavingsField(.unit)
|
|
saveFuelSavingsField(.amount)
|
|
// A drag in progress on the old scale no longer maps to this one.
|
|
fillUpDraft = nil
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Describes the fill-up amount in the unit the user picked, so the copy stays
|
|
/// true whether the amount is a budget in pounds or a volume in litres.
|
|
private var fillUpAmountDescription: String {
|
|
switch fuelSavingsSettings.preferredAmount.unit {
|
|
case .pounds: return "How much you plan to spend on fuel."
|
|
case .litres: return "How many litres you usually buy."
|
|
}
|
|
}
|
|
|
|
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
|
/// is the one place the figure is stated. It follows the draft while the
|
|
/// thumb is down.
|
|
private var mpgText: String {
|
|
let mpg = mpgDraft ?? fuelSavingsSettings.mpg
|
|
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
|
}
|
|
|
|
/// "£40" / "40 L" — the fill-up slider's read-out, draft-first like the MPG one.
|
|
private var fillUpText: String {
|
|
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
|
|
switch fuelSavingsSettings.preferredAmount.unit {
|
|
case .pounds: return String(format: "£%.0f", value)
|
|
case .litres: return String(format: "%.0f L", value)
|
|
}
|
|
}
|
|
|
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
|
private var connectionText: String {
|
|
switch FuelStore.loadRelaySource()?.lowercased() {
|
|
case "api": return "API"
|
|
case "csv": return "CSV"
|
|
default: return "—"
|
|
}
|
|
}
|
|
|
|
private var stationCountText: String {
|
|
guard let count = FuelStore.loadStationCount() else { return "—" }
|
|
return count.formatted()
|
|
}
|
|
|
|
/// GOV.UK server's own dataset update timestamp (ISO 8601 from the API,
|
|
/// e.g. 2026-08-12T10:23:00.000Z). Shown as a local date/time — it is the
|
|
/// data's own freshness, not the relay's sync time.
|
|
private var dataUpdatedText: String {
|
|
guard let raw = FuelStore.loadDataUpdated(), !raw.isEmpty else { return "—" }
|
|
let iso = ISO8601DateFormatter()
|
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = iso.date(from: raw) {
|
|
return date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
// Some sources send "YYYY-MM-DDTHH:MM:SS" without fractional seconds
|
|
// or timezone — try the plain form before showing the raw string.
|
|
iso.formatOptions = [.withInternetDateTime]
|
|
if let date = iso.date(from: raw) {
|
|
return date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/// Hidden debug-mode toggle: five taps on the Version row flips the flag.
|
|
/// Nothing in the UI advertises this — the Debug section simply appears or
|
|
/// disappears. Deliberately NOT a visible switch so end users never see it.
|
|
private func handleVersionTap() {
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastVersionTap) > 0.6 {
|
|
versionTapCount = 0
|
|
}
|
|
lastVersionTap = now
|
|
versionTapCount += 1
|
|
if versionTapCount >= 5 {
|
|
versionTapCount = 0
|
|
debugMode.toggle()
|
|
FuelStore.saveDebugMode(debugMode)
|
|
}
|
|
}
|
|
|
|
// MARK: - Alert trace rendering
|
|
|
|
private func alertSymbol(for kind: AlertLogEntry.Kind) -> String {
|
|
switch kind {
|
|
case .entry: return "location.circle.fill"
|
|
case .gate: return "arrow.right.circle"
|
|
case .error: return "xmark.octagon.fill"
|
|
case .fired: return "bell.badge.fill"
|
|
}
|
|
}
|
|
|
|
private func alertColor(for kind: AlertLogEntry.Kind) -> Color {
|
|
switch kind {
|
|
case .entry: return .green
|
|
case .gate: return .orange
|
|
case .error: return .red
|
|
case .fired: return .blue
|
|
}
|
|
}
|
|
|
|
private func sendTestAlert() {
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
Task { @MainActor in
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
onTestAlert()
|
|
case .denied:
|
|
testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
|
|
default:
|
|
// First time — ask, then fire if granted.
|
|
Task { @MainActor in
|
|
do {
|
|
let granted = try await UNUserNotificationCenter.current()
|
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
|
if granted {
|
|
onTestAlert()
|
|
} else {
|
|
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
|
|
}
|
|
} catch {
|
|
testAlertMessage = NSLocalizedString("Couldn't request notification permission right now. Please try again.", comment: "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Loads the three tip tiers and drives their purchases.
|
|
@MainActor
|
|
final class TipStore: ObservableObject {
|
|
/// A purchasable tip tier (App Store Connect — all consumables).
|
|
struct TipTier: Identifiable {
|
|
let id: String // product ID
|
|
let name: String
|
|
let blurb: 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.",
|
|
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.",
|
|
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.",
|
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
|
]
|
|
|
|
/// A banner outcome: the exact copy to show plus its icon + tint.
|
|
struct TipOutcome: Hashable {
|
|
let message: String
|
|
let icon: String
|
|
let tint: Color
|
|
}
|
|
|
|
@Published private(set) var products: [String: Product] = [:]
|
|
@Published private(set) var purchaseInProgress = false
|
|
@Published private(set) var outcome: TipOutcome?
|
|
@Published private(set) var productLoadStatus = "Not probed"
|
|
@Published private(set) var lastProductLoadDate: Date?
|
|
@Published private(set) var lastProductLoadError: String?
|
|
@Published private(set) var debugProducts: [DebugProduct] = []
|
|
|
|
struct DebugProduct: Identifiable {
|
|
let id: String
|
|
let displayName: String
|
|
let type: String
|
|
let displayPrice: String
|
|
let price: String
|
|
}
|
|
|
|
var missingProductIDs: [String] {
|
|
Self.tiers.map(\.id).filter { products[$0] == nil }
|
|
}
|
|
|
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
|
private var dismissTask: Task<Void, Never>?
|
|
|
|
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
|
/// call — Ask to Buy approvals, payments finished on another device
|
|
/// signed into the same Apple ID. Without this, those purchases land in
|
|
/// the queue but are never finished or acknowledged.
|
|
private var updatesTask: Task<Void, Never>?
|
|
|
|
/// Drains verified FuelBoard transactions that were already unfinished
|
|
/// before this store was created (for example after termination during a
|
|
/// sandbox purchase). `Transaction.updates` only covers updates delivered
|
|
/// while the listener is active, so both sequences are required.
|
|
private var unfinishedTask: Task<Void, Never>?
|
|
|
|
/// Uses the existing hidden developer switch in Settings. StoreKit
|
|
/// diagnostics must stay silent unless the developer explicitly enables
|
|
/// Debug mode with the five-tap Version gesture.
|
|
private static func debugLog(_ message: @autoclosure () -> String) {
|
|
guard FuelStore.loadDebugMode() else { return }
|
|
print(message())
|
|
}
|
|
|
|
init() {
|
|
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
|
|
updatesTask = Task { [weak self] in
|
|
for await update in Transaction.updates {
|
|
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
|
|
await self?.handle(update)
|
|
}
|
|
}
|
|
unfinishedTask = Task { [weak self] in
|
|
for await update in Transaction.unfinished {
|
|
Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received")
|
|
await self?.handle(update)
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
updatesTask?.cancel()
|
|
unfinishedTask?.cancel()
|
|
dismissTask?.cancel()
|
|
}
|
|
|
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
|
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
|
|
// Never deliver or finish an untrusted purchase.
|
|
guard case .verified(let transaction) = update else {
|
|
if case .unverified(_, let error) = update {
|
|
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)")
|
|
} else {
|
|
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
|
|
}
|
|
return
|
|
}
|
|
// Only acknowledge our own consumables (future products get their own).
|
|
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else {
|
|
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: ""),
|
|
icon: "checkmark.circle.fill",
|
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
|
)
|
|
}
|
|
|
|
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
|
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
|
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
|
dismissTask?.cancel()
|
|
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
|
dismissTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
|
guard !Task.isCancelled else { return }
|
|
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
|
}
|
|
}
|
|
|
|
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
|
func dismissOutcome() {
|
|
dismissTask?.cancel()
|
|
dismissTask = nil
|
|
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
|
}
|
|
|
|
func displayPrice(for tier: TipTier) -> String {
|
|
products[tier.id]?.displayPrice
|
|
?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit")
|
|
}
|
|
|
|
func load() async {
|
|
// Refreshes product state on every visit so newly-approved products
|
|
// (or restored transactions) are picked up.
|
|
let ids = Self.tiers.map(\.id)
|
|
productLoadStatus = "Loading…"
|
|
lastProductLoadError = nil
|
|
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
|
|
do {
|
|
let fetched = try await Product.products(for: ids)
|
|
lastProductLoadDate = Date()
|
|
Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
|
for product in fetched {
|
|
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)")
|
|
}
|
|
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
|
debugProducts = fetched.map {
|
|
DebugProduct(
|
|
id: $0.id,
|
|
displayName: $0.displayName,
|
|
type: String(describing: $0.type),
|
|
displayPrice: $0.displayPrice,
|
|
price: String(describing: $0.price)
|
|
)
|
|
}
|
|
productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed"
|
|
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
|
|
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
|
|
} catch {
|
|
lastProductLoadDate = Date()
|
|
productLoadStatus = "Failed"
|
|
lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)"
|
|
debugProducts = []
|
|
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
|
// Keep the cards visible so the user can retry while App Store
|
|
// Connect metadata propagates; displayPrice() shows "Unavailable"
|
|
// until StoreKit returns a real product.
|
|
products = [:]
|
|
}
|
|
}
|
|
|
|
private func merge(_ fetched: [Product]) {
|
|
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
|
|
}
|
|
|
|
private func product(for tier: TipTier) async throws -> Product? {
|
|
if let product = products[tier.id] {
|
|
Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)")
|
|
return product
|
|
}
|
|
|
|
// TestFlight/App Store Connect product availability can lag the Settings
|
|
// onAppear load. Re-query the tapped tier before falling back so a newly
|
|
// approved IAP opens the system purchase sheet instead of showing stale
|
|
// "not available" copy.
|
|
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
|
|
let fetched = try await Product.products(for: [tier.id])
|
|
Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
|
merge(fetched)
|
|
let match = fetched.first { $0.id == tier.id }
|
|
Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)")
|
|
return match
|
|
}
|
|
|
|
func purchase(_ tier: TipTier) async {
|
|
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 }
|
|
|
|
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()
|
|
showOutcome(
|
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
|
icon: "checkmark.circle.fill",
|
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
|
)
|
|
case .unverified:
|
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
|
|
showOutcome(
|
|
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
|
icon: "exclamationmark.triangle.fill",
|
|
tint: .orange
|
|
)
|
|
}
|
|
case .userCancelled:
|
|
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",
|
|
tint: .red
|
|
)
|
|
}
|
|
}
|
|
}
|