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. /// 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) // 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).") 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")
} }
+18 -7
View File
@@ -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()
+2
View File
@@ -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),
+1 -1
View File
@@ -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")
+3 -2
View File
@@ -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"
} }
} }
+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 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 {
+9 -6
View File
@@ -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
View File
@@ -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] {