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
+13 -18
View File
@@ -4,10 +4,14 @@ import SwiftUI
/// being monitored.
struct AlertsView: View {
@Binding var enabled: Bool
@Binding var radius: Double
@Binding var radius: Double // stored in km (monitor + storage)
let distanceUnit: DistanceUnit
let monitoredCount: Int
let lastAlert: String?
var onShowOnboarding: () -> Void = {}
/// The radius slider works in the user's chosen unit; the stored value
/// stays km so ProximityMonitor and persistence never change.
private var radiusInUnit: Double { distanceUnit.fromKM(radius) }
var body: some View {
NavigationStack {
@@ -24,11 +28,15 @@ struct AlertsView: View {
HStack {
Text("Radius")
Spacer()
Text("\(Int(radius)) km")
Text(String(format: "%.1f %@", radiusInUnit, distanceUnit.shortName))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $radius, in: 1...10, step: 1)
// 110 in the user's unit; convert back to km on change.
Slider(value: Binding(
get: { radiusInUnit },
set: { radius = distanceUnit.toKM($0) }
), in: 1...10, step: distanceUnit == .kilometers ? 1 : 0.5)
}
}
@@ -42,7 +50,7 @@ struct AlertsView: View {
Text("Your favourites get priority, then the closest stations fill the rest (18 max, iOS region limit).")
.font(.caption)
.foregroundStyle(.secondary)
Text("Alerts are checked against the cheapest station within \(Int(radius)) km for the selected fuel. Each station alerts at most once per hour.")
Text("Alerts are checked against the cheapest station within the trigger radius for the selected fuel. Each station alerts at most once per hour.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -55,19 +63,6 @@ struct AlertsView: View {
}
}
}
// Testing hook in production onboarding shows once at first
// launch; this button re-opens it to verify the flow.
Section {
Button {
onShowOnboarding()
} label: {
Label("Show onboarding (testing)", systemImage: "flag.fill")
.font(.footnote)
}
} footer: {
Text("Testing only — onboarding normally appears once on first launch.")
}
}
.navigationTitle("Alerts")
}
+18 -7
View File
@@ -9,6 +9,7 @@ struct ContentView: View {
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var favourites: [FuelStation] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@@ -31,7 +32,7 @@ struct ContentView: View {
let selling = stations.filter { $0.prices[selectedFuel] != nil }
guard let location else { return selling }
if sortMode == .closest { return selling } // radius disabled in Closest
let radiusKM = Double(stationLimit) * 1.60934 // chosen miles km
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units km
return selling.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
@@ -47,7 +48,7 @@ struct ContentView: View {
private var baselinePrice: Double? {
let pool = poolStations
if sortMode == .closest, let location {
let radiusKM = Double(stationLimit) * 1.60934 // chosen miles km
let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units km
let within = pool.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
@@ -111,6 +112,7 @@ struct ContentView: View {
selectedFuel: $selectedFuel,
sortMode: $sortMode,
stationLimit: $stationLimit,
distanceUnit: distanceUnit,
baselinePrice: baselinePrice,
topStationID: topStationID,
location: location,
@@ -124,6 +126,7 @@ struct ContentView: View {
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite
)
@@ -132,11 +135,17 @@ struct ContentView: View {
AlertsView(
enabled: $alertsEnabled,
radius: $alertsRadius,
distanceUnit: distanceUnit,
monitoredCount: monitor.monitoredStationIDs.count,
lastAlert: monitor.lastAlert,
onShowOnboarding: { showOnboarding = true }
lastAlert: monitor.lastAlert
)
.tabItem { Label("Alerts", systemImage: "bell.fill") }
SettingsView(
distanceUnit: $distanceUnit,
onShowOnboarding: { showOnboarding = true }
)
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
}
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView {
@@ -191,8 +200,9 @@ struct ContentView: View {
}
.onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK
// dump, so changing 5/10/15 miles never needs a network fetch.
// sortedStations/radiusScopedStations recompute on the next render.
// dump, so changing 5/10/15 (miles or km) never needs a network
// fetch. sortedStations/radiusScopedStations recompute on the
// next render.
FuelStore.saveStationLimit(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
@@ -259,6 +269,7 @@ struct StationRow: View {
let station: FuelStation
let fuel: FuelType
let location: Coordinate?
let distanceUnit: DistanceUnit
let baselinePrice: Double?
let isTopResult: Bool
let isFavourite: Bool
@@ -325,7 +336,7 @@ struct StationRow: View {
.lineLimit(1)
.truncationMode(.tail)
if let location {
Text(String(format: "%.1f mi", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371))
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
+2
View File
@@ -6,6 +6,7 @@ struct FavouritesView: View {
let favourites: [FuelStation]
let selectedFuel: FuelType
let location: Coordinate?
let distanceUnit: DistanceUnit
let favouriteIDs: Set<String>
var onToggleFavourite: (FuelStation) -> Void = { _ in }
@@ -62,6 +63,7 @@ struct FavouritesView: View {
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isFavourite: favouriteIDs.contains(station.id),
+1 -1
View File
@@ -94,7 +94,7 @@ struct OnboardingView: View {
.padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day")
featureRow(icon: "scope", text: "Cheapest within 5/10/15 miles, or closest station first")
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
+3 -2
View File
@@ -155,14 +155,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
private func fireAlert(for station: FuelStation, price: Double) {
let brand = station.brand.isEmpty ? station.name : station.brand
let unit = FuelStore.loadDistanceUnit()
let content = UNMutableNotificationContent()
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name) is the cheapest within \(Int(radiusKM)) km at \(String(format: "%.1fp", price)). Tap to open."
content.body = "\(station.name) is the cheapest within \(unit.format(radiusKM)) at \(String(format: "%.1fp", price)). Tap to open."
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(Int(radiusKM)) km radius"
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(unit.format(radiusKM)) radius"
}
}
+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)"
}
}
}
+9 -7
View File
@@ -9,6 +9,7 @@ struct StationsView: View {
@Binding var selectedFuel: FuelType
@Binding var sortMode: SortMode
@Binding var stationLimit: Int
let distanceUnit: DistanceUnit
let baselinePrice: Double?
let topStationID: String?
let location: Coordinate?
@@ -29,11 +30,11 @@ struct StationsView: View {
Section {
if let location {
if sortMode == .closest {
Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(stationLimit) miles.")
Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(stationLimit) \(distanceUnit.label).")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Cheapest \(selectedFuel.displayName) within \(stationLimit) miles — tap a station for directions.")
Text("Cheapest \(selectedFuel.displayName) within \(stationLimit) \(distanceUnit.label) — tap a station for directions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -60,7 +61,7 @@ struct StationsView: View {
Section("Distance") {
Picker("Distance", selection: $stationLimit) {
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text("\(miles) miles").tag(miles)
Text("\(miles) \(distanceUnit.label)").tag(miles)
}
}
.pickerStyle(.segmented)
@@ -79,7 +80,7 @@ struct StationsView: View {
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("\(totalCount) stations within \(stationLimit) miles")
Text("\(totalCount) stations within \(stationLimit) \(distanceUnit.label)")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -111,6 +112,7 @@ struct StationsView: View {
station: station,
fuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
@@ -193,12 +195,12 @@ struct StationsView: View {
// MARK: - Fuel-type iconography (app target only FuelStore.swift is Foundation-only)
extension FuelType {
/// Short segment label ("Unleaded (E10)" / "Premium (E5)" / "Diesel") for
/// Short segment label ("Unleaded" / "Premium" / "Diesel") for
/// the picker and description.
var shortName: String {
switch self {
case .e10: return "Unleaded (E10)"
case .e5: return "Premium (E5)"
case .e10: return "Unleaded"
case .e5: return "Premium"
case .diesel: return "Diesel"
}
}