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:
+13
-18
@@ -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)
|
||||
// 1–10 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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user