Sort toggle (cheapest/closest) + RAG value rating per station with TOP badge
This commit is contained in:
@@ -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]!
|
||||
@@ -28,16 +43,29 @@ struct ContentView: View {
|
||||
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) {
|
||||
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] {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(ragColor)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(String(format: "%.1fp", price))
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if let deltaText {
|
||||
Text(deltaText)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(ragColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
Image(systemName: "arrow.triangle.turn.up.right.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -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] {
|
||||
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(.green)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user