- New 'Send plain test notification' button fires with zero criteria checks (no geofence, no cheapest check, no permission gate) to isolate delivery and tap behaviour - ProximityMonitor is now the UNUserNotificationCenter delegate: banners show even in the foreground (system suppresses them otherwise), and taps complete the 'open FuelBoard' action
197 lines
8.0 KiB
Swift
197 lines
8.0 KiB
Swift
import SwiftUI
|
|
import StoreKit
|
|
import UserNotifications
|
|
import WidgetKit
|
|
|
|
/// Settings tab — distance units, onboarding replay, a tip jar, and a
|
|
/// test-alert button that previews what a real price notification looks like.
|
|
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
|
|
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", systemImage: "bell.badge.fill")
|
|
}
|
|
if let testAlertMessage {
|
|
Text(testAlertMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Button {
|
|
ProximityMonitor.sendPlainTestNotification()
|
|
} label: {
|
|
Label("Send plain test notification", systemImage: "bell.slash.fill")
|
|
}
|
|
} header: {
|
|
Text("Alerts")
|
|
} footer: {
|
|
Text("The first button sends a notification shaped like a real price alert for \(alertsFuel.displayName.lowercased()) within \(distanceUnit.format(alertsRadiusKM)). The second fires with no criteria at all — no geofence, no cheapest check — just to verify a notification appears and tapping it opens FuelBoard.")
|
|
}
|
|
|
|
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:
|
|
ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM)
|
|
testAlertMessage = "Test alert sent — check your notification centre."
|
|
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 {
|
|
ProximityMonitor.sendTestNotification(fuel: alertsFuel, radiusKM: alertsRadiusKM)
|
|
testAlertMessage = "Test alert sent — check your notification centre."
|
|
} 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)"
|
|
}
|
|
}
|
|
}
|