ProximityMonitor now records each stage the live chain reaches (Settings → Debug → Alert trace, in-memory, newest-first, capped at 8): - didEnterRegion arrival (regionEventCount + entry line) — distinguishes 'geofence never fires' from 'gate blocked the alert' - gate misses: station not in loaded list, hourly dedup (with minutes ago), no fuel sellers within radius after fresh fetch, entered-not-cheapest (with the cheaper station + price) - the previously-silent fresh-fetch catch now logs the error - 'alert scheduled' once UNUserNotificationCenter.add is called — if that line appears but no banner shows, the failure is delivery/permission, not alert logic SettingsView shows the trace (symbol per kind) + region-event count; the existing Test-alert button already probes notification permission and reports denial inline. 83 tests, Release build green.
585 lines
27 KiB
Swift
585 lines
27 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 {
|
|
@Binding var distanceUnit: DistanceUnit
|
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
|
/// 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
|
|
|
|
@StateObject private var tipStore = TipStore()
|
|
@State private var showTipAlert = false
|
|
@State private var tipAlertMessage = ""
|
|
@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()
|
|
@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 = ""
|
|
|
|
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 {
|
|
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()
|
|
}
|
|
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()
|
|
}
|
|
} header: {
|
|
Text("Units")
|
|
} footer: {
|
|
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 {
|
|
Button {
|
|
onShowOnboarding()
|
|
} label: {
|
|
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
|
}
|
|
} footer: {
|
|
Text("Replay the welcome screen, including the location and notification permission prompts.")
|
|
}
|
|
|
|
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 (nearest 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 {
|
|
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 whether prices come from the official Fuel Finder API or the CSV mirror. Data updated is the latest price change reported by the GOV.UK server itself.")
|
|
}
|
|
|
|
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 {
|
|
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 {
|
|
Button {
|
|
Task { await tipStore.purchase() }
|
|
} label: {
|
|
HStack {
|
|
Label("Leave a tip", systemImage: "heart.fill")
|
|
.foregroundStyle(.pink)
|
|
Spacer()
|
|
Text(tipStore.displayPrice)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.disabled(tipStore.purchaseInProgress)
|
|
} header: {
|
|
Text("Support FuelBoard")
|
|
} footer: {
|
|
Text("A small tip helps keep the data relay and app development going. Thank you!")
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Text("Version")
|
|
Spacer()
|
|
Text(appVersion)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { handleVersionTap() }
|
|
}
|
|
} header: {
|
|
Text("About")
|
|
}
|
|
|
|
if let message = tipStore.message {
|
|
Section {
|
|
Text(message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.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 let coord = status.coordinate {
|
|
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))"
|
|
}
|
|
|
|
/// "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.
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
|
Task { @MainActor in
|
|
if granted {
|
|
onTestAlert()
|
|
} else {
|
|
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Loads the £4.99 tip product and drives its purchase.
|
|
@MainActor
|
|
final class TipStore: ObservableObject {
|
|
/// Product ID for the £4.99 tip (App Store Connect — consumable).
|
|
static let productID = "com.apt.fuelboard.tip499"
|
|
|
|
@Published private(set) var product: Product?
|
|
@Published private(set) var purchaseInProgress = false
|
|
@Published private(set) var message: String?
|
|
|
|
var displayPrice: String {
|
|
product?.displayPrice ?? "£4.99"
|
|
}
|
|
|
|
func load() async {
|
|
// Refreshes product state on every visit so a newly-approved product
|
|
// (or a restored transaction) is picked up.
|
|
do {
|
|
let products = try await Product.products(for: [Self.productID])
|
|
product = products.first
|
|
} catch {
|
|
// No product yet (sideloaded build) — the button still shows the
|
|
// intended price and reports the purchase attempt gracefully.
|
|
product = nil
|
|
}
|
|
}
|
|
|
|
func purchase() async {
|
|
guard !purchaseInProgress else { 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 else {
|
|
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
|
|
return
|
|
}
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
switch verification {
|
|
case .verified:
|
|
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
|
case .unverified:
|
|
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
|
}
|
|
case .userCancelled:
|
|
message = nil // silent — the user just closed the sheet
|
|
case .pending:
|
|
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
|
@unknown default:
|
|
message = nil
|
|
}
|
|
} catch {
|
|
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
|
}
|
|
}
|
|
}
|