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:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user