diff --git a/Config/App-Info.plist b/Config/App-Info.plist
index 161d11e..893d1b5 100644
--- a/Config/App-Info.plist
+++ b/Config/App-Info.plist
@@ -24,6 +24,12 @@
NSLocationWhenInUseUsageDescription
FuelBoard uses your location to find the cheapest nearby petrol stations.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ FuelBoard uses Always location to alert you when you approach the cheapest station nearby.
+ UIBackgroundModes
+
+ location
+
UILaunchScreen
UIRequiredDeviceCapabilities
diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift
new file mode 100644
index 0000000..8513471
--- /dev/null
+++ b/FuelBoard/AlertsView.swift
@@ -0,0 +1,61 @@
+import SwiftUI
+
+/// Alerts tab — enables the cheapest-station proximity alerts and shows what's
+/// being monitored.
+struct AlertsView: View {
+ @Binding var enabled: Bool
+ @Binding var radius: Double
+ let monitoredCount: Int
+ let lastAlert: String?
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Toggle("Cheapest-station alerts", isOn: $enabled)
+ } footer: {
+ Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed.")
+ }
+
+ if enabled {
+ Section("Trigger radius") {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Radius")
+ Spacer()
+ Text("\(Int(radius)) km")
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+ Slider(value: $radius, in: 1...10, step: 1)
+ }
+ }
+
+ Section("Monitoring") {
+ if monitoredCount == 0 {
+ Text("No stations monitored yet — open the Stations tab to load prices first.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ LabeledContent("Geofenced stations", value: "\(monitoredCount)")
+ Text("Your favourites get priority, then the closest stations fill the rest (18 max, iOS region limit).")
+ .font(.caption)
+ .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.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if let lastAlert {
+ Section("Last alert") {
+ Label(lastAlert, systemImage: "bell.badge.fill")
+ .font(.footnote)
+ }
+ }
+ }
+ }
+ .navigationTitle("Alerts")
+ }
+ }
+}
diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift
index 73ae5ec..97c57a2 100644
--- a/FuelBoard/ContentView.swift
+++ b/FuelBoard/ContentView.swift
@@ -9,6 +9,9 @@ struct ContentView: View {
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
+ @State private var favourites: [FuelStation] = FuelStore.loadFavourites()
+ @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
+ @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
@@ -16,6 +19,7 @@ struct ContentView: View {
@State private var isLoading = false
@State private var statusMessage = ""
@State private var locationManager = LocationManager()
+ @StateObject private var monitor = ProximityMonitor()
private var cheapestPrice: Double? {
stations.compactMap { $0.prices[selectedFuel] }.min()
@@ -50,148 +54,93 @@ struct ContentView: View {
}
}
+ private var favouriteIDs: Set {
+ Set(favourites.map(\.id))
+ }
+
+ private var refreshedFavourites: [FuelStation] {
+ FuelStore.refreshedFavourites(favourites, from: stations)
+ }
+
var body: some View {
- NavigationStack {
- List {
- Section {
- Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
+ TabView {
+ StationsView(
+ stations: displayedStations,
+ totalCount: sortedStations.count,
+ isLoading: isLoading,
+ selectedFuel: $selectedFuel,
+ sortMode: $sortMode,
+ stationLimit: $stationLimit,
+ cheapestPrice: cheapestPrice,
+ location: location,
+ favouriteIDs: favouriteIDs,
+ onToggleFavourite: toggleFavourite
+ )
+ .tabItem { Label("Stations", systemImage: "fuelpump.fill") }
- Section("Fuel type") {
- Picker("Fuel type", selection: $selectedFuel) {
- ForEach(FuelType.allCases) { fuel in
- Text(fuel.displayName).tag(fuel)
- }
- }
- .pickerStyle(.segmented)
- .onChange(of: selectedFuel) { _, newValue in
- FuelStore.saveSelectedFuel(newValue)
- WidgetCenter.shared.reloadAllTimelines()
- }
- }
+ FavouritesView(
+ favourites: refreshedFavourites,
+ selectedFuel: selectedFuel,
+ location: location,
+ favouriteIDs: favouriteIDs,
+ onToggleFavourite: toggleFavourite
+ )
+ .tabItem { Label("Favourites", systemImage: "star.fill") }
- Section("Stations") {
- 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)
- }
- .padding(.vertical, 2)
-
- if isLoading {
- HStack(spacing: 10) {
- ProgressView()
- Text("Fetching prices…")
- }
- } else if displayedStations.isEmpty {
- Text("No \(selectedFuel.displayName) stations found.")
- .foregroundStyle(.secondary)
- } else {
- ForEach(Array(displayedStations.enumerated()), id: \.element.id) { index, station in
- StationRow(
- station: station,
- fuel: selectedFuel,
- location: location,
- cheapestPrice: cheapestPrice,
- isTopResult: index == 0
- )
- }
- }
-
- if !displayedStations.isEmpty {
- Divider()
- Picker("Show", selection: $stationLimit) {
- ForEach([10, 25, 50, 75, 100], id: \.self) { count in
- Text("\(count)").tag(count)
- }
- }
- .pickerStyle(.segmented)
- .onChange(of: stationLimit) { _, newValue in
- FuelStore.saveStationLimit(newValue)
- }
- .padding(.vertical, 2)
- Text("Showing \(displayedStations.count) of \(sortedStations.count) stations")
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
-
- Section("Key") {
- HStack(spacing: 8) {
- Circle().fill(.green).frame(width: 12, height: 12)
- Text("Best value — within 1.5p of the cheapest")
- .font(.caption)
- }
- HStack(spacing: 8) {
- Circle().fill(.orange).frame(width: 12, height: 12)
- Text("Okay — within 4p of the cheapest")
- .font(.caption)
- }
- HStack(spacing: 8) {
- Circle().fill(.red).frame(width: 12, height: 12)
- Text("Pricey — more than 4p over the cheapest")
- .font(.caption)
- }
- HStack(spacing: 8) {
- Text("TOP")
- .font(.caption2.bold())
- .padding(.horizontal, 5)
- .padding(.vertical, 1)
- .background(Capsule().fill(.blue.opacity(0.15)))
- .foregroundStyle(.blue)
- Text("Top result for the current sort")
- .font(.caption)
- }
- }
-
- if !statusMessage.isEmpty {
- Section("Status") {
- Text(statusMessage)
- .font(.caption2)
- .monospaced()
- .textSelection(.enabled)
- }
- }
- }
- .navigationTitle("FuelBoard")
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button {
- Task { await refresh() }
- } label: {
- Image(systemName: "arrow.clockwise")
- }
- .disabled(isLoading)
- }
- }
- .onAppear {
- // Request a fresh fix on every launch so "Closest" stays
- // accurate and the widget gets fresh coords.
+ AlertsView(
+ enabled: $alertsEnabled,
+ radius: $alertsRadius,
+ monitoredCount: monitor.monitoredStationIDs.count,
+ lastAlert: monitor.lastAlert
+ )
+ .tabItem { Label("Alerts", systemImage: "bell.fill") }
+ }
+ .onAppear {
+ locationManager.requestUpdate()
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
+ monitor.setEnabled(alertsEnabled)
+ Task { await refresh() }
+ }
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active {
locationManager.requestUpdate()
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
Task { await refresh() }
}
- .onChange(of: scenePhase) { _, newPhase in
- if newPhase == .active {
- // Re-request location on foreground too.
- locationManager.requestUpdate()
- Task { await refresh() }
- }
- }
- .onChange(of: locationManager.current) { _, newLocation in
- if let newLocation {
- location = newLocation
- FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
- WidgetCenter.shared.reloadAllTimelines()
- Task { await refresh() }
- }
+ }
+ .onChange(of: locationManager.current) { _, newLocation in
+ if let newLocation {
+ location = newLocation
+ FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
+ WidgetCenter.shared.reloadAllTimelines()
+ Task { await refresh() }
}
}
+ .onChange(of: alertsEnabled) { _, newValue in
+ FuelStore.saveAlertsEnabled(newValue)
+ monitor.setEnabled(newValue)
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
+ }
+ .onChange(of: alertsRadius) { _, newValue in
+ FuelStore.saveAlertsRadius(newValue)
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
+ }
+ }
+
+ private func toggleFavourite(_ station: FuelStation) {
+ if favouriteIDs.contains(station.id) {
+ favourites.removeAll { $0.id == station.id }
+ } else {
+ favourites.append(station)
+ }
+ FuelStore.saveFavourites(favourites)
+ WidgetCenter.shared.reloadAllTimelines()
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
}
private func refresh() async {
@@ -207,15 +156,22 @@ struct ContentView: View {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
}
+ // Keep monitor geofences in sync with the freshest data.
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: selectedFuel, radiusKM: alertsRadius)
}
}
+// MARK: - Station row (shared by Stations + Favourites tabs)
+
struct StationRow: View {
let station: FuelStation
let fuel: FuelType
let location: Coordinate?
let cheapestPrice: Double?
let isTopResult: Bool
+ let isFavourite: Bool
+ var onToggleFavourite: () -> Void = {}
private var ragColor: Color {
guard let price = station.prices[fuel], let cheapestPrice else { return .gray }
@@ -258,6 +214,7 @@ struct StationRow: View {
HStack(spacing: 6) {
Text(station.name)
.font(.headline)
+ .lineLimit(1)
if isTopResult {
Text("TOP")
.font(.caption2.bold())
@@ -270,6 +227,7 @@ struct StationRow: View {
Text("\(station.address), \(station.postcode)")
.font(.caption)
.foregroundStyle(.secondary)
+ .lineLimit(1)
if let location {
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
@@ -293,8 +251,13 @@ struct StationRow: View {
}
}
}
- Image(systemName: "arrow.triangle.turn.up.right.circle")
- .foregroundStyle(.secondary)
+ Button {
+ onToggleFavourite()
+ } label: {
+ Image(systemName: isFavourite ? "star.fill" : "star")
+ .foregroundStyle(isFavourite ? .yellow : .secondary)
+ }
+ .buttonStyle(.borderless)
}
.contentShape(Rectangle())
.onTapGesture {
diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift
new file mode 100644
index 0000000..4ec1ca7
--- /dev/null
+++ b/FuelBoard/FavouritesView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+/// Favourites tab — starred stations, ranked cheapest-first for the selected
+/// fuel, with the cheapest favourite called out at the top.
+struct FavouritesView: View {
+ let favourites: [FuelStation]
+ let selectedFuel: FuelType
+ let location: Coordinate?
+ let favouriteIDs: Set
+ var onToggleFavourite: (FuelStation) -> Void = { _ in }
+
+ /// Favourites that sell the selected fuel, cheapest first (distance tiebreak).
+ private var ranked: [FuelStation] {
+ let available = favourites.filter { $0.prices[selectedFuel] != nil }
+ return available.sorted { lhs, rhs in
+ let lPrice = lhs.prices[selectedFuel]!
+ let rPrice = rhs.prices[selectedFuel]!
+ if lPrice != rPrice { return lPrice < rPrice }
+ guard let location else { return false }
+ return lhs.distanceKM(to: location.lat, lng2: location.lng) <
+ rhs.distanceKM(to: location.lat, lng2: location.lng)
+ }
+ }
+
+ private var cheapest: FuelStation? { ranked.first }
+ private var cheapestPrice: Double? { cheapest?.prices[selectedFuel] }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ if favourites.isEmpty {
+ Section {
+ VStack(spacing: 10) {
+ Image(systemName: "star")
+ .font(.system(size: 40))
+ .foregroundStyle(.secondary)
+ Text("No favourites yet")
+ .font(.headline)
+ Text("Tap the star on any station in the Stations tab to pin it here, ranked by which is cheapest.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 24)
+ }
+ } else {
+ Section {
+ if let cheapest, let price = cheapestPrice {
+ HStack(spacing: 10) {
+ Image(systemName: "crown.fill")
+ .foregroundStyle(.yellow)
+ Text("Cheapest favourite: \(cheapest.name) — \(String(format: "%.1fp", price))")
+ .font(.footnote)
+ }
+ }
+ }
+
+ Section("Favourites — cheapest first") {
+ ForEach(Array(ranked.enumerated()), id: \.element.id) { index, station in
+ StationRow(
+ station: station,
+ fuel: selectedFuel,
+ location: location,
+ cheapestPrice: cheapestPrice,
+ isTopResult: index == 0,
+ isFavourite: favouriteIDs.contains(station.id),
+ onToggleFavourite: { onToggleFavourite(station) }
+ )
+ }
+ }
+
+ if let cheapest = cheapest {
+ Section {
+ Text("\(cheapest.name) is your cheapest favourite for \(selectedFuel.displayName). Add the same stations to the Alerts tab to get pinged when you drive past a cheaper one.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .navigationTitle("Favourites")
+ }
+ }
+}
diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift
new file mode 100644
index 0000000..5902d1a
--- /dev/null
+++ b/FuelBoard/ProximityMonitor.swift
@@ -0,0 +1,153 @@
+import CoreLocation
+import UserNotifications
+import SwiftUI
+
+/// Geofences favourite + closest stations and fires a local notification when
+/// the user enters a station whose price is the cheapest within the radius.
+///
+/// Region budget: iOS allows 20 monitored regions per app — favourites are
+/// always registered first, then the closest remaining stations fill the rest.
+@MainActor
+final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
+ @Published var monitoredStationIDs: [String] = []
+ @Published var lastAlert: String?
+
+ private let manager = CLLocationManager()
+ private var stations: [FuelStation] = []
+ private var favourites: [FuelStation] = []
+ private var fuel: FuelType = .e10
+ private var radiusKM: Double = 3.0
+ private var lastNotified: [String: Date] = [:] // dedup per station
+ private var enabled = false
+
+ override init() {
+ super.init()
+ manager.delegate = self
+ manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
+ manager.pausesLocationUpdatesAutomatically = true
+ }
+
+ /// Re-registers geofences. Call whenever stations/favourites/settings change.
+ func update(stations: [FuelStation], favourites: [FuelStation], fuel: FuelType, radiusKM: Double) {
+ self.stations = stations
+ self.favourites = favourites
+ self.fuel = fuel
+ self.radiusKM = radiusKM
+
+ for region in manager.monitoredRegions {
+ manager.stopMonitoring(for: region)
+ }
+ monitoredStationIDs = []
+
+ guard enabled else { return }
+
+ // Favourites first (guaranteed slots), then closest stations, max 18.
+ var candidates: [FuelStation] = favourites
+ let favIDs = Set(favourites.map(\.id))
+ let location = FuelStore.loadLocation()
+ let others = stations
+ .filter { !favIDs.contains($0.id) }
+ .sorted { lhs, rhs in
+ guard let location else { return false }
+ return lhs.distanceKM(to: location.lat, lng2: location.lng) <
+ rhs.distanceKM(to: location.lat, lng2: location.lng)
+ }
+ candidates.append(contentsOf: others)
+
+ var registered: [String] = []
+ for station in candidates.prefix(18) where station.prices[fuel] != nil {
+ let region = CLCircularRegion(
+ center: CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng),
+ radius: 300,
+ identifier: station.id
+ )
+ region.notifyOnEntry = true
+ region.notifyOnExit = false
+ manager.startMonitoring(for: region)
+ registered.append(station.id)
+ }
+ monitoredStationIDs = registered
+ }
+
+ func setEnabled(_ enabled: Bool) {
+ self.enabled = enabled
+ if !enabled {
+ for region in manager.monitoredRegions {
+ manager.stopMonitoring(for: region)
+ }
+ monitoredStationIDs = []
+ } else {
+ requestPermissions()
+ }
+ }
+
+ var isEnabled: Bool { enabled }
+
+ private func requestPermissions() {
+ // Region monitoring needs Always location for background delivery.
+ switch manager.authorizationStatus {
+ case .notDetermined:
+ manager.requestWhenInUseAuthorization()
+ manager.requestAlwaysAuthorization()
+ case .authorizedWhenInUse:
+ manager.requestAlwaysAuthorization()
+ default:
+ break
+ }
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
+ }
+
+ // MARK: - Region events
+
+ nonisolated func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
+ Task { @MainActor in
+ self.handleEntry(region)
+ }
+ }
+
+ nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
+ // Ignore — alerts still work while the app is open.
+ }
+
+ private func handleEntry(_ region: CLRegion) {
+ let stationID = region.identifier
+ guard let station = stations.first(where: { $0.id == stationID }) else { return }
+
+ // Dedup: one alert per station per hour.
+ if let last = lastNotified[stationID], Date().timeIntervalSince(last) < 3600 { return }
+
+ Task {
+ do {
+ // Fresh prices around the entered station.
+ let fresh = try await FuelPriceProvider.active.fetchStations(
+ near: station.lat, lng: station.lng, fuel: fuel
+ )
+ let withinRadius = fresh.filter {
+ $0.prices[fuel] != nil &&
+ $0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
+ }
+ guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }),
+ cheapest.id == station.id,
+ let price = station.prices[fuel] else { return }
+
+ fireAlert(for: station, price: price)
+ lastNotified[stationID] = Date()
+ } catch {
+ // Silent — geofence state stays valid for next entry.
+ }
+ }
+ }
+
+ private func fireAlert(for station: FuelStation, price: Double) {
+ let brand = station.brand.isEmpty ? station.name : station.brand
+ let content = UNMutableNotificationContent()
+ 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.sound = .default
+
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request)
+
+ lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(Int(radiusKM)) km radius"
+ }
+}
diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift
new file mode 100644
index 0000000..9a71b9f
--- /dev/null
+++ b/FuelBoard/StationsView.swift
@@ -0,0 +1,129 @@
+import SwiftUI
+import WidgetKit
+
+/// Stations tab — the main list with fuel picker, sort, station count limit.
+struct StationsView: View {
+ let stations: [FuelStation]
+ let totalCount: Int
+ let isLoading: Bool
+ @Binding var selectedFuel: FuelType
+ @Binding var sortMode: SortMode
+ @Binding var stationLimit: Int
+ let cheapestPrice: Double?
+ let location: Coordinate?
+ let favouriteIDs: Set
+ var onToggleFavourite: (FuelStation) -> Void = { _ in }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Fuel type") {
+ Picker("Fuel type", selection: $selectedFuel) {
+ ForEach(FuelType.allCases) { fuel in
+ Text(fuel.displayName).tag(fuel)
+ }
+ }
+ .pickerStyle(.segmented)
+ .onChange(of: selectedFuel) { _, newValue in
+ FuelStore.saveSelectedFuel(newValue)
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+ }
+
+ Section("Stations") {
+ 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)
+ }
+ .padding(.vertical, 2)
+
+ if isLoading {
+ HStack(spacing: 10) {
+ ProgressView()
+ Text("Fetching prices…")
+ }
+ } else if stations.isEmpty {
+ Text("No \(selectedFuel.displayName) stations found.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(stations.enumerated()), id: \.element.id) { index, station in
+ StationRow(
+ station: station,
+ fuel: selectedFuel,
+ location: location,
+ cheapestPrice: cheapestPrice,
+ isTopResult: index == 0,
+ isFavourite: favouriteIDs.contains(station.id),
+ onToggleFavourite: { onToggleFavourite(station) }
+ )
+ }
+ }
+
+ if !stations.isEmpty {
+ Divider()
+ Picker("Show", selection: $stationLimit) {
+ ForEach([10, 25, 50, 75, 100], id: \.self) { count in
+ Text("\(count)").tag(count)
+ }
+ }
+ .pickerStyle(.segmented)
+ .onChange(of: stationLimit) { _, newValue in
+ FuelStore.saveStationLimit(newValue)
+ }
+ .padding(.vertical, 2)
+ Text("Showing \(stations.count) of \(totalCount) stations")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Key") {
+ HStack(spacing: 8) {
+ Circle().fill(.green).frame(width: 12, height: 12)
+ Text("Best value — within 1.5p of the cheapest")
+ .font(.caption)
+ }
+ HStack(spacing: 8) {
+ Circle().fill(.orange).frame(width: 12, height: 12)
+ Text("Okay — within 4p of the cheapest")
+ .font(.caption)
+ }
+ HStack(spacing: 8) {
+ Circle().fill(.red).frame(width: 12, height: 12)
+ Text("Pricey — more than 4p over the cheapest")
+ .font(.caption)
+ }
+ HStack(spacing: 8) {
+ Text("TOP")
+ .font(.caption2.bold())
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(Capsule().fill(.blue.opacity(0.15)))
+ .foregroundStyle(.blue)
+ Text("Top result for the current sort")
+ .font(.caption)
+ }
+ HStack(spacing: 8) {
+ Image(systemName: "star.fill")
+ .font(.caption2)
+ .foregroundStyle(.yellow)
+ Text("Star a station to add it to Favourites")
+ .font(.caption)
+ }
+ }
+ }
+ .navigationTitle("FuelBoard")
+ }
+ }
+}
diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift
index 98bbd35..4495524 100644
--- a/Shared/FuelStore.swift
+++ b/Shared/FuelStore.swift
@@ -127,6 +127,9 @@ struct FuelStore {
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
static let stationLimitKey = "fuelboard.stationLimit" // Int (10/25/50/75/100)
+ static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
+ static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
+ static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
// MARK: Stations
@@ -204,6 +207,61 @@ struct FuelStore {
saveString(String(limit), service: stationLimitKey)
}
+ // MARK: Favourites
+
+ static func loadFavourites() -> [FuelStation] {
+ if let data = keychainData(service: favouritesKey),
+ let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
+ return favs
+ }
+ if let defaults = UserDefaults(suiteName: appGroupSuite),
+ let data = defaults.data(forKey: favouritesKey),
+ let favs = try? JSONDecoder().decode([FuelStation].self, from: data) {
+ return favs
+ }
+ return []
+ }
+
+ static func saveFavourites(_ favourites: [FuelStation]) {
+ if let data = try? JSONEncoder().encode(favourites) {
+ UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: favouritesKey)
+ writeKeychain(data: data, service: favouritesKey)
+ }
+ }
+
+ /// Returns favourites with fresh prices applied from the given station list
+ /// (favourites keep their cached snapshot when not in the current results).
+ static func refreshedFavourites(_ favourites: [FuelStation], from stations: [FuelStation]) -> [FuelStation] {
+ var updated = favourites
+ for (i, fav) in favourites.enumerated() {
+ if let fresh = stations.first(where: { $0.id == fav.id }) {
+ updated[i] = fresh
+ }
+ }
+ return updated
+ }
+
+ // MARK: Alerts
+
+ static func loadAlertsEnabled() -> Bool {
+ loadString(service: alertsEnabledKey) == "1"
+ }
+
+ static func saveAlertsEnabled(_ enabled: Bool) {
+ saveString(enabled ? "1" : "0", service: alertsEnabledKey)
+ }
+
+ static func loadAlertsRadius() -> Double {
+ if let raw = loadString(service: alertsRadiusKey), let value = Double(raw), value >= 1, value <= 10 {
+ return value
+ }
+ return 3.0
+ }
+
+ static func saveAlertsRadius(_ radius: Double) {
+ saveString(String(radius), service: alertsRadiusKey)
+ }
+
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {