Files
fuelboard/FuelBoard/SettingsView.swift
T
FuelBoard Contributor 9032c4a551 Both test buttons carry real station coords; map status surfaced
- Plain test notification now picks the nearest station selling the
  monitored fuel and carries its real name, price, distance and
  coordinates (was a static no-station notification)
- addAlertRequest reports whether the map snapshot attached or why it
  failed, appended to the Settings result line instead of silently
  delivering a notification without the embedded map
- Settings routes both test buttons through the live monitor
2026-08-12 10:06:48 +01:00

210 lines
8.6 KiB
Swift

import SwiftUI
import StoreKit
import UserNotifications
import WidgetKit
/// Settings tab — distance units, onboarding replay, a tip jar, and
/// test-alert buttons. 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.
struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit
/// 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 = {}
var onShowOnboarding: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String?
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()
}
} header: {
Text("Units")
} footer: {
Text("Distances and search radii across the app, widget and alerts are shown in this unit.")
}
Section {
Button {
onShowOnboarding()
} label: {
Label("Show introduction", systemImage: "sparkles")
}
} footer: {
Text("Replay the welcome screen, including the location and notification permission prompts.")
}
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)
}
Button {
onPlainTestAlert()
} label: {
Label("Send plain test notification", systemImage: "bell.slash.fill")
}
} header: {
Text("Alerts")
} 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.")
}
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!")
}
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.onAppear {
Task { await tipStore.load() }
}
}
}
private func sendTestAlert() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
Task { @MainActor in
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
onTestAlert()
case .denied:
testAlertMessage = "Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again."
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 = "Notifications weren't allowed, so no test alert was sent."
}
}
}
}
}
}
}
}
/// 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 = "The tip isn't available in this build yet — check back after an App Store release."
return
}
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified:
message = "Thank you! Your tip has been received. ⛽"
case .unverified:
message = "The purchase couldn't be verified. Please try again."
}
case .userCancelled:
message = nil // silent — the user just closed the sheet
case .pending:
message = "Your tip is pending approval. It'll finish automatically."
@unknown default:
message = nil
}
} catch {
message = "The tip couldn't be completed: \(error.localizedDescription)"
}
}
}