Sort toggle (cheapest/closest) + RAG value rating per station with TOP badge

This commit is contained in:
FuelBoard Contributor
2026-08-11 14:14:07 +01:00
parent 543212a721
commit d9ec2f8c35
3 changed files with 147 additions and 15 deletions
+84 -11
View File
@@ -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)
+19 -4
View File
@@ -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
}
}
}
+44
View File
@@ -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? {