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.
|
/// being monitored.
|
||||||
struct AlertsView: View {
|
struct AlertsView: View {
|
||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
let monitoredCount: Int
|
let monitoredCount: Int
|
||||||
let lastAlert: String?
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -24,11 +28,15 @@ struct AlertsView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Radius")
|
Text("Radius")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(radius)) km")
|
Text(String(format: "%.1f %@", radiusInUnit, distanceUnit.shortName))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.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).")
|
Text("Your favourites get priority, then the closest stations fill the rest (18 max, iOS region limit).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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")
|
.navigationTitle("Alerts")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct ContentView: View {
|
|||||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||||
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@State private var favourites: [FuelStation] = FuelStore.loadFavourites()
|
@State private var favourites: [FuelStation] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@@ -31,7 +32,7 @@ struct ContentView: View {
|
|||||||
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
let selling = stations.filter { $0.prices[selectedFuel] != nil }
|
||||||
guard let location else { return selling }
|
guard let location else { return selling }
|
||||||
if sortMode == .closest { return selling } // radius disabled in Closest
|
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 {
|
return selling.filter {
|
||||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ struct ContentView: View {
|
|||||||
private var baselinePrice: Double? {
|
private var baselinePrice: Double? {
|
||||||
let pool = poolStations
|
let pool = poolStations
|
||||||
if sortMode == .closest, let location {
|
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 {
|
let within = pool.filter {
|
||||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,7 @@ struct ContentView: View {
|
|||||||
selectedFuel: $selectedFuel,
|
selectedFuel: $selectedFuel,
|
||||||
sortMode: $sortMode,
|
sortMode: $sortMode,
|
||||||
stationLimit: $stationLimit,
|
stationLimit: $stationLimit,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
topStationID: topStationID,
|
topStationID: topStationID,
|
||||||
location: location,
|
location: location,
|
||||||
@@ -124,6 +126,7 @@ struct ContentView: View {
|
|||||||
favourites: refreshedFavourites,
|
favourites: refreshedFavourites,
|
||||||
selectedFuel: selectedFuel,
|
selectedFuel: selectedFuel,
|
||||||
location: location,
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
onToggleFavourite: toggleFavourite
|
onToggleFavourite: toggleFavourite
|
||||||
)
|
)
|
||||||
@@ -132,11 +135,17 @@ struct ContentView: View {
|
|||||||
AlertsView(
|
AlertsView(
|
||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
lastAlert: monitor.lastAlert,
|
lastAlert: monitor.lastAlert
|
||||||
onShowOnboarding: { showOnboarding = true }
|
|
||||||
)
|
)
|
||||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
|
|
||||||
|
SettingsView(
|
||||||
|
distanceUnit: $distanceUnit,
|
||||||
|
onShowOnboarding: { showOnboarding = true }
|
||||||
|
)
|
||||||
|
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
@@ -191,8 +200,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: stationLimit) { _, newValue in
|
.onChange(of: stationLimit) { _, newValue in
|
||||||
// Distance filter is LOCAL math now — the cache holds the full-UK
|
// Distance filter is LOCAL math now — the cache holds the full-UK
|
||||||
// dump, so changing 5/10/15 miles never needs a network fetch.
|
// dump, so changing 5/10/15 (miles or km) never needs a network
|
||||||
// sortedStations/radiusScopedStations recompute on the next render.
|
// fetch. sortedStations/radiusScopedStations recompute on the
|
||||||
|
// next render.
|
||||||
FuelStore.saveStationLimit(newValue)
|
FuelStore.saveStationLimit(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
@@ -259,6 +269,7 @@ struct StationRow: View {
|
|||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let fuel: FuelType
|
let fuel: FuelType
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
@@ -325,7 +336,7 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let location {
|
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)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct FavouritesView: View {
|
|||||||
let favourites: [FuelStation]
|
let favourites: [FuelStation]
|
||||||
let selectedFuel: FuelType
|
let selectedFuel: FuelType
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
var onToggleFavourite: (FuelStation) -> Void = { _ in }
|
var onToggleFavourite: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ struct FavouritesView: View {
|
|||||||
station: station,
|
station: station,
|
||||||
fuel: selectedFuel,
|
fuel: selectedFuel,
|
||||||
location: location,
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: index == 0,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ struct OnboardingView: View {
|
|||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day")
|
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: "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: "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")
|
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) {
|
private func fireAlert(for station: FuelStation, price: Double) {
|
||||||
let brand = station.brand.isEmpty ? station.name : station.brand
|
let brand = station.brand.isEmpty ? station.name : station.brand
|
||||||
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "Cheapest \(fuel.displayName) nearby: \(brand)"
|
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
|
content.sound = .default
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
UNUserNotificationCenter.current().add(request)
|
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 selectedFuel: FuelType
|
||||||
@Binding var sortMode: SortMode
|
@Binding var sortMode: SortMode
|
||||||
@Binding var stationLimit: Int
|
@Binding var stationLimit: Int
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let topStationID: String?
|
let topStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
@@ -29,11 +30,11 @@ struct StationsView: View {
|
|||||||
Section {
|
Section {
|
||||||
if let location {
|
if let location {
|
||||||
if sortMode == .closest {
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} 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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ struct StationsView: View {
|
|||||||
Section("Distance") {
|
Section("Distance") {
|
||||||
Picker("Distance", selection: $stationLimit) {
|
Picker("Distance", selection: $stationLimit) {
|
||||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||||
Text("\(miles) miles").tag(miles)
|
Text("\(miles) \(distanceUnit.label)").tag(miles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
@@ -79,7 +80,7 @@ struct StationsView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("\(totalCount) stations within \(stationLimit) miles")
|
Text("\(totalCount) stations within \(stationLimit) \(distanceUnit.label)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,7 @@ struct StationsView: View {
|
|||||||
station: station,
|
station: station,
|
||||||
fuel: selectedFuel,
|
fuel: selectedFuel,
|
||||||
location: location,
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
@@ -193,12 +195,12 @@ struct StationsView: View {
|
|||||||
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
||||||
|
|
||||||
extension FuelType {
|
extension FuelType {
|
||||||
/// Short segment label ("Unleaded (E10)" / "Premium (E5)" / "Diesel") for
|
/// Short segment label ("Unleaded" / "Premium" / "Diesel") for
|
||||||
/// the picker and description.
|
/// the picker and description.
|
||||||
var shortName: String {
|
var shortName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .e10: return "Unleaded (E10)"
|
case .e10: return "Unleaded"
|
||||||
case .e5: return "Premium (E5)"
|
case .e5: return "Premium"
|
||||||
case .diesel: return "Diesel"
|
case .diesel: return "Diesel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,10 +196,23 @@ final class BrandTests: XCTestCase {
|
|||||||
|
|
||||||
final class FuelTypeLabelTests: XCTestCase {
|
final class FuelTypeLabelTests: XCTestCase {
|
||||||
func testDisplayNames() {
|
func testDisplayNames() {
|
||||||
XCTAssertEqual(FuelType.e10.displayName, "Unleaded (E10)")
|
// E10/E5 grades are intentionally not part of user-facing labels.
|
||||||
XCTAssertEqual(FuelType.e5.displayName, "Premium (E5)")
|
XCTAssertEqual(FuelType.e10.displayName, "Unleaded")
|
||||||
|
XCTAssertEqual(FuelType.e5.displayName, "Premium")
|
||||||
XCTAssertEqual(FuelType.diesel.displayName, "Diesel")
|
XCTAssertEqual(FuelType.diesel.displayName, "Diesel")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDistanceUnitConversion() {
|
||||||
|
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001)
|
||||||
|
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
|
||||||
|
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001)
|
||||||
|
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDistanceUnitFormat() {
|
||||||
|
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi")
|
||||||
|
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class FavouriteRefreshTests: XCTestCase {
|
final class FavouriteRefreshTests: XCTestCase {
|
||||||
|
|||||||
@@ -36,15 +36,17 @@ struct FuelPriceEntry: TimelineEntry {
|
|||||||
let fuel: FuelType
|
let fuel: FuelType
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let locationSource: String // "live" | "cached" | "none"
|
let locationSource: String // "live" | "cached" | "none"
|
||||||
|
let unit: DistanceUnit // user's display unit for distances
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceTimelineProvider: TimelineProvider {
|
struct FuelPriceTimelineProvider: TimelineProvider {
|
||||||
func placeholder(in context: Context) -> FuelPriceEntry {
|
func placeholder(in context: Context) -> FuelPriceEntry {
|
||||||
let fuel = FuelStore.loadSelectedFuel()
|
let fuel = FuelStore.loadSelectedFuel()
|
||||||
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
let sample = SampleFuelProvider.sampleStations
|
let sample = SampleFuelProvider.sampleStations
|
||||||
.filter { $0.prices[fuel] != nil }
|
.filter { $0.prices[fuel] != nil }
|
||||||
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none")
|
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none", unit: unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
|
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
|
||||||
@@ -85,11 +87,12 @@ struct FuelPriceTimelineProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) Load stations, then sort by price (distance tiebreak), scoped to
|
// 3) Load stations, then sort by price (distance tiebreak), scoped to
|
||||||
// the chosen search radius (miles) so the widget's "cheapest" matches
|
// the chosen search radius so the widget's "cheapest" matches
|
||||||
// the app's list.
|
// the app's list.
|
||||||
var stations = FuelStore.loadStations()
|
var stations = FuelStore.loadStations()
|
||||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||||
let radiusKM = Double(FuelStore.loadStationLimit()) * 1.60934 // miles → km
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
|
let radiusKM = unit.toKM(Double(FuelStore.loadStationLimit())) // chosen units → km
|
||||||
if let location {
|
if let location {
|
||||||
// STRICT: cached data fetched around another location must never
|
// STRICT: cached data fetched around another location must never
|
||||||
// leak out-of-radius stations into the widget.
|
// leak out-of-radius stations into the widget.
|
||||||
@@ -112,7 +115,7 @@ struct FuelPriceTimelineProvider: TimelineProvider {
|
|||||||
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source)
|
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source, unit: FuelStore.loadDistanceUnit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +161,7 @@ struct FuelPriceWidgetView: View {
|
|||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(String(format: "%.1f mi away", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371))
|
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@@ -192,7 +195,7 @@ struct FuelPriceWidgetView: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(String(format: "%.1f km", station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-4
@@ -48,21 +48,75 @@ enum RAGRating: Int, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||||
case e10 // Unleaded 95 (E10)
|
case e10 // Unleaded (E10)
|
||||||
case e5 // Premium 97/98 (E5)
|
case e5 // Premium (E5)
|
||||||
case diesel // B7 diesel
|
case diesel // B7 diesel
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .e10: return "Unleaded (E10)"
|
case .e10: return "Unleaded"
|
||||||
case .e5: return "Premium (E5)"
|
case .e5: return "Premium"
|
||||||
case .diesel: return "Diesel"
|
case .diesel: return "Diesel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display unit for all distances in the app + widget. Internally distances
|
||||||
|
/// are always stored/computed in km; conversion happens at the display and
|
||||||
|
/// filter boundary so nothing else needs to know the unit.
|
||||||
|
enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case miles
|
||||||
|
case kilometers
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .miles: return "Miles"
|
||||||
|
case .kilometers: return "Kilometres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short suffix for values ("5 mi", "3.2 km").
|
||||||
|
var shortName: String {
|
||||||
|
switch self {
|
||||||
|
case .miles: return "mi"
|
||||||
|
case .kilometers: return "km"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full word for narrative text ("within 5 miles", "within 8 km").
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .miles: return "miles"
|
||||||
|
case .kilometers: return "km"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a value expressed in this unit to km.
|
||||||
|
func toKM(_ value: Double) -> Double {
|
||||||
|
switch self {
|
||||||
|
case .miles: return value * 1.60934
|
||||||
|
case .kilometers: return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a km value to this unit.
|
||||||
|
func fromKM(_ km: Double) -> Double {
|
||||||
|
switch self {
|
||||||
|
case .miles: return km * 0.621371
|
||||||
|
case .kilometers: return km
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a km distance in this unit ("1.2 mi", "3.4 km").
|
||||||
|
func format(_ km: Double) -> String {
|
||||||
|
String(format: "%.1f %@", fromKM(km), shortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Station model
|
// MARK: - Station model
|
||||||
|
|
||||||
struct FuelStation: Identifiable, Codable, Equatable {
|
struct FuelStation: Identifiable, Codable, Equatable {
|
||||||
@@ -179,6 +233,7 @@ struct FuelStore {
|
|||||||
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
|
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
|
||||||
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
|
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
|
||||||
static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15)
|
static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15)
|
||||||
|
static let distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value
|
||||||
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
|
||||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
@@ -277,6 +332,20 @@ struct FuelStore {
|
|||||||
saveString(String(miles), service: stationLimitKey)
|
saveString(String(miles), service: stationLimitKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Distance unit — miles or kilometres. Stored raw value; default
|
||||||
|
// miles for backward compatibility with pre-toggle installs.
|
||||||
|
|
||||||
|
static func loadDistanceUnit() -> DistanceUnit {
|
||||||
|
if let raw = loadString(service: distanceUnitKey), let unit = DistanceUnit(rawValue: raw) {
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
return .miles
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDistanceUnit(_ unit: DistanceUnit) {
|
||||||
|
saveString(unit.rawValue, service: distanceUnitKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Favourites
|
// MARK: Favourites
|
||||||
|
|
||||||
static func loadFavourites() -> [FuelStation] {
|
static func loadFavourites() -> [FuelStation] {
|
||||||
|
|||||||
Reference in New Issue
Block a user