Add Settings tab: distance units toggle, onboarding replay, £4.99 tip; drop E10/E5 from labels

- FuelType labels: 'Unleaded (E10)'/'Premium (E5)' -> 'Unleaded'/'Premium'
- New DistanceUnit (miles/km) preference; all app + widget distances,
  radii and alert text convert/display in the chosen unit
- Settings tab (4th tab): segmented miles/km picker, 'Show introduction'
  replay button (onboarding moved out of Alerts tab), StoreKit tip £4.99
- ProximityMonitor + widget notifications use the chosen unit
- Tests: label expectations updated, DistanceUnit conversion + format tests
This commit is contained in:
FuelBoard Contributor
2026-08-12 07:46:08 +01:00
parent fb79cf0788
commit 44c6c38536
10 changed files with 284 additions and 47 deletions
+141
View File
@@ -0,0 +1,141 @@
import SwiftUI
import StoreKit
import WidgetKit
/// Settings tab distance units, onboarding replay, and a tip jar.
struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit
var onShowOnboarding: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
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 {
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() }
}
}
}
}
/// 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)"
}
}
}