Sort toggle (cheapest/closest) + RAG value rating per station with TOP badge
This commit is contained in:
+84
-11
@@ -7,6 +7,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@State private var stations: [FuelStation] = []
|
@State private var stations: [FuelStation] = []
|
||||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||||
@State private var location: Coordinate? = {
|
@State private var location: Coordinate? = {
|
||||||
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
||||||
return nil
|
return nil
|
||||||
@@ -15,10 +16,24 @@ struct ContentView: View {
|
|||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
|
|
||||||
|
private var cheapestPrice: Double? {
|
||||||
|
stations.compactMap { $0.prices[selectedFuel] }.min()
|
||||||
|
}
|
||||||
|
|
||||||
private var sortedStations: [FuelStation] {
|
private var sortedStations: [FuelStation] {
|
||||||
stations
|
let available = stations.filter { $0.prices[selectedFuel] != nil }
|
||||||
.filter { $0.prices[selectedFuel] != nil }
|
switch sortMode {
|
||||||
.sorted { lhs, rhs in
|
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.
|
// Cheapest first; distance only breaks ties.
|
||||||
let lPrice = lhs.prices[selectedFuel]!
|
let lPrice = lhs.prices[selectedFuel]!
|
||||||
let rPrice = rhs.prices[selectedFuel]!
|
let rPrice = rhs.prices[selectedFuel]!
|
||||||
@@ -27,17 +42,30 @@ struct ContentView: View {
|
|||||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
||||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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") {
|
Section("Fuel type") {
|
||||||
Picker("Fuel type", selection: $selectedFuel) {
|
Picker("Fuel type", selection: $selectedFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
@@ -61,8 +89,14 @@ struct ContentView: View {
|
|||||||
Text("No \(selectedFuel.displayName) stations found.")
|
Text("No \(selectedFuel.displayName) stations found.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(sortedStations) { station in
|
ForEach(Array(sortedStations.enumerated()), id: \.element.id) { index, station in
|
||||||
StationRow(station: station, fuel: selectedFuel, location: location)
|
StationRow(
|
||||||
|
station: station,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
location: location,
|
||||||
|
cheapestPrice: cheapestPrice,
|
||||||
|
isTopResult: index == 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,12 +181,40 @@ struct StationRow: View {
|
|||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let fuel: FuelType
|
let fuel: FuelType
|
||||||
let location: Coordinate?
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(station.name)
|
HStack(spacing: 6) {
|
||||||
.font(.headline)
|
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)")
|
Text("\(station.address), \(station.postcode)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -164,9 +226,20 @@ struct StationRow: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if let price = station.prices[fuel] {
|
if let price = station.prices[fuel] {
|
||||||
Text(String(format: "%.1fp", price))
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
.font(.title3.bold())
|
HStack(spacing: 5) {
|
||||||
.foregroundStyle(.green)
|
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")
|
Image(systemName: "arrow.triangle.turn.up.right.circle")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ struct FuelPriceWidgetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var stationList: some 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 {
|
HStack {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
@@ -187,9 +188,14 @@ struct FuelPriceWidgetView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if let price = station.prices[entry.fuel] {
|
if let price = station.prices[entry.fuel] {
|
||||||
Text(String(format: "%.1fp", price))
|
HStack(spacing: 4) {
|
||||||
.font(.caption.weight(.bold))
|
Circle()
|
||||||
.foregroundStyle(.green)
|
.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)
|
.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
|
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 {
|
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||||
case e10 // Unleaded 95 (E10)
|
case e10 // Unleaded 95 (E10)
|
||||||
case e5 // Premium 97/98 (E5)
|
case e5 // Premium 97/98 (E5)
|
||||||
@@ -72,6 +102,7 @@ struct FuelStore {
|
|||||||
static let stationsKey = "fuelboard.stations" // [FuelStation] JSON
|
static let stationsKey = "fuelboard.stations" // [FuelStation] JSON
|
||||||
static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp"
|
static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp"
|
||||||
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
|
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
|
||||||
|
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
|
||||||
|
|
||||||
// MARK: Stations
|
// MARK: Stations
|
||||||
|
|
||||||
@@ -123,6 +154,19 @@ struct FuelStore {
|
|||||||
saveString(fuel.rawValue, service: fuelKey)
|
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
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
|
|||||||
Reference in New Issue
Block a user