From d9ec2f8c35019ac3ca448edc02c1dcec91d868d8 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Tue, 11 Aug 2026 14:14:07 +0100 Subject: [PATCH] Sort toggle (cheapest/closest) + RAG value rating per station with TOP badge --- FuelBoard/ContentView.swift | 95 +++++++++++++++++++++++--- FuelBoardWidgets/FuelPriceWidget.swift | 23 +++++-- Shared/FuelStore.swift | 44 ++++++++++++ 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index d4bd163..59d5745 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { @State private var stations: [FuelStation] = [] @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() + @State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var location: Coordinate? = { if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) } return nil @@ -15,10 +16,24 @@ struct ContentView: View { @State private var statusMessage = "" @State private var locationManager = LocationManager() + private var cheapestPrice: Double? { + stations.compactMap { $0.prices[selectedFuel] }.min() + } + private var sortedStations: [FuelStation] { - stations - .filter { $0.prices[selectedFuel] != nil } - .sorted { lhs, rhs in + let available = stations.filter { $0.prices[selectedFuel] != nil } + switch sortMode { + case .closest: + guard let location else { return available.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } } + return available.sorted { lhs, rhs in + // Closest first; price only breaks ties. + let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng) + let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng) + if lDist != rDist { return lDist < rDist } + return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]! + } + case .cheapest: + return available.sorted { lhs, rhs in // Cheapest first; distance only breaks ties. let lPrice = lhs.prices[selectedFuel]! let rPrice = rhs.prices[selectedFuel]! @@ -27,17 +42,30 @@ struct ContentView: View { return lhs.distanceKM(to: location.lat, lng2: location.lng) < rhs.distanceKM(to: location.lat, lng2: location.lng) } + } } var body: some View { NavigationStack { List { Section { - Text("Cheapest \(selectedFuel.displayName) near you. Tap a station for directions.") + Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions. 🟢 great value · 🟡 okay · 🔴 pricey") .font(.footnote) .foregroundStyle(.secondary) } + Section("Sort by") { + Picker("Sort by", selection: $sortMode) { + ForEach(SortMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .onChange(of: sortMode) { _, newValue in + FuelStore.saveSortMode(newValue) + } + } + Section("Fuel type") { Picker("Fuel type", selection: $selectedFuel) { ForEach(FuelType.allCases) { fuel in @@ -61,8 +89,14 @@ struct ContentView: View { Text("No \(selectedFuel.displayName) stations found.") .foregroundStyle(.secondary) } else { - ForEach(sortedStations) { station in - StationRow(station: station, fuel: selectedFuel, location: location) + ForEach(Array(sortedStations.enumerated()), id: \.element.id) { index, station in + StationRow( + station: station, + fuel: selectedFuel, + location: location, + cheapestPrice: cheapestPrice, + isTopResult: index == 0 + ) } } } @@ -147,12 +181,40 @@ struct StationRow: View { let station: FuelStation let fuel: FuelType let location: Coordinate? + let cheapestPrice: Double? + let isTopResult: Bool + + private var ragColor: Color { + guard let price = station.prices[fuel], let cheapestPrice else { return .gray } + switch RAGRating.rating(price: price, cheapest: cheapestPrice) { + case .green: return .green + case .amber: return .orange + case .red: return .red + } + } + + private var deltaText: String? { + guard let price = station.prices[fuel], let cheapestPrice else { return nil } + let delta = price - cheapestPrice + if delta <= 0.05 { return "best" } + return String(format: "+%.1fp", delta) + } var body: some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { - Text(station.name) - .font(.headline) + HStack(spacing: 6) { + Text(station.name) + .font(.headline) + if isTopResult { + Text("TOP") + .font(.caption2.bold()) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(.blue.opacity(0.15))) + .foregroundStyle(.blue) + } + } Text("\(station.address), \(station.postcode)") .font(.caption) .foregroundStyle(.secondary) @@ -164,9 +226,20 @@ struct StationRow: View { } Spacer() if let price = station.prices[fuel] { - Text(String(format: "%.1fp", price)) - .font(.title3.bold()) - .foregroundStyle(.green) + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 5) { + Circle() + .fill(ragColor) + .frame(width: 10, height: 10) + Text(String(format: "%.1fp", price)) + .font(.title3.bold()) + } + if let deltaText { + Text(deltaText) + .font(.caption2.bold()) + .foregroundStyle(ragColor) + } + } } Image(systemName: "arrow.triangle.turn.up.right.circle") .foregroundStyle(.secondary) diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 8f85b94..792fe22 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -162,7 +162,8 @@ struct FuelPriceWidgetView: View { } private var stationList: some View { - VStack(alignment: .leading, spacing: 6) { + let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min() + return VStack(alignment: .leading, spacing: 6) { HStack { Image(systemName: "fuelpump.fill") .foregroundStyle(.green) @@ -187,9 +188,14 @@ struct FuelPriceWidgetView: View { } Spacer() if let price = station.prices[entry.fuel] { - Text(String(format: "%.1fp", price)) - .font(.caption.weight(.bold)) - .foregroundStyle(.green) + HStack(spacing: 4) { + Circle() + .fill(ragColor(for: price, cheapest: cheapest)) + .frame(width: 6, height: 6) + Text(String(format: "%.1fp", price)) + .font(.caption.weight(.bold)) + .foregroundStyle(.primary) + } } } } @@ -199,4 +205,13 @@ struct FuelPriceWidgetView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private func ragColor(for price: Double, cheapest: Double?) -> Color { + guard let cheapest else { return .gray } + switch RAGRating.rating(price: price, cheapest: cheapest) { + case .green: return .green + case .amber: return .orange + case .red: return .red + } + } } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 156e6f4..62ef9d9 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -17,6 +17,36 @@ struct Coordinate: Equatable, Codable { let lng: Double } +enum SortMode: String, Codable, CaseIterable, Identifiable { + case cheapest + case closest + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cheapest: return "Cheapest" + case .closest: return "Closest" + } + } +} + +/// RAG value rating for a station's price against the cheapest available. +/// Thumb rules: within 1.5p = green (great value), within 4p = amber (okay), +/// beyond that = red (pricey). Deliberately coarse so it reads at a glance. +enum RAGRating: Int, Codable { + case green = 0 + case amber = 1 + case red = 2 + + static func rating(price: Double, cheapest: Double) -> RAGRating { + let delta = price - cheapest + if delta <= 1.5 { return .green } + if delta <= 4.0 { return .amber } + return .red + } +} + enum FuelType: String, Codable, CaseIterable, Identifiable { case e10 // Unleaded 95 (E10) case e5 // Premium 97/98 (E5) @@ -72,6 +102,7 @@ struct FuelStore { static let stationsKey = "fuelboard.stations" // [FuelStation] JSON static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp" static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value + static let sortModeKey = "fuelboard.sortMode" // SortMode raw value // MARK: Stations @@ -123,6 +154,19 @@ struct FuelStore { saveString(fuel.rawValue, service: fuelKey) } + // MARK: Sort mode + + static func loadSortMode() -> SortMode { + if let raw = loadString(service: sortModeKey), let mode = SortMode(rawValue: raw) { + return mode + } + return .cheapest + } + + static func saveSortMode(_ mode: SortMode) { + saveString(mode.rawValue, service: sortModeKey) + } + // MARK: Low-level keychain helpers private static func keychainData(service: String) -> Data? {