Tabs (Stations/Favourites/Alerts): favourites with cheapest-first ranking, proximity geofence alerts (favourites priority, 3km default radius, 1h dedup), Always location + background mode

This commit is contained in:
FuelBoard Contributor
2026-08-11 15:17:26 +01:00
parent 54403ed4b3
commit 60d25164d6
7 changed files with 591 additions and 136 deletions
+6
View File
@@ -24,6 +24,12 @@
<true/> <true/>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
+61
View File
@@ -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")
}
}
}
+99 -136
View File
@@ -9,6 +9,9 @@ struct ContentView: View {
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @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? = { @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
@@ -16,6 +19,7 @@ struct ContentView: View {
@State private var isLoading = false @State private var isLoading = false
@State private var statusMessage = "" @State private var statusMessage = ""
@State private var locationManager = LocationManager() @State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor()
private var cheapestPrice: Double? { private var cheapestPrice: Double? {
stations.compactMap { $0.prices[selectedFuel] }.min() stations.compactMap { $0.prices[selectedFuel] }.min()
@@ -50,148 +54,93 @@ struct ContentView: View {
} }
} }
private var favouriteIDs: Set<String> {
Set(favourites.map(\.id))
}
private var refreshedFavourites: [FuelStation] {
FuelStore.refreshedFavourites(favourites, from: stations)
}
var body: some View { var body: some View {
NavigationStack { TabView {
List { StationsView(
Section { stations: displayedStations,
Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.") totalCount: sortedStations.count,
.font(.footnote) isLoading: isLoading,
.foregroundStyle(.secondary) selectedFuel: $selectedFuel,
} sortMode: $sortMode,
stationLimit: $stationLimit,
cheapestPrice: cheapestPrice,
location: location,
favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite
)
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
Section("Fuel type") { FavouritesView(
Picker("Fuel type", selection: $selectedFuel) { favourites: refreshedFavourites,
ForEach(FuelType.allCases) { fuel in selectedFuel: selectedFuel,
Text(fuel.displayName).tag(fuel) location: location,
} favouriteIDs: favouriteIDs,
} onToggleFavourite: toggleFavourite
.pickerStyle(.segmented) )
.onChange(of: selectedFuel) { _, newValue in .tabItem { Label("Favourites", systemImage: "star.fill") }
FuelStore.saveSelectedFuel(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
}
Section("Stations") { AlertsView(
Picker("Sort by", selection: $sortMode) { enabled: $alertsEnabled,
ForEach(SortMode.allCases) { mode in radius: $alertsRadius,
Text(mode.displayName).tag(mode) monitoredCount: monitor.monitoredStationIDs.count,
} lastAlert: monitor.lastAlert
} )
.pickerStyle(.segmented) .tabItem { Label("Alerts", systemImage: "bell.fill") }
.onChange(of: sortMode) { _, newValue in }
FuelStore.saveSortMode(newValue) .onAppear {
} locationManager.requestUpdate()
.padding(.vertical, 2) monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
if isLoading { monitor.setEnabled(alertsEnabled)
HStack(spacing: 10) { Task { await refresh() }
ProgressView() }
Text("Fetching prices…") .onChange(of: scenePhase) { _, newPhase in
} if newPhase == .active {
} 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.
locationManager.requestUpdate() locationManager.requestUpdate()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
Task { await refresh() } Task { await refresh() }
} }
.onChange(of: scenePhase) { _, newPhase in }
if newPhase == .active { .onChange(of: locationManager.current) { _, newLocation in
// Re-request location on foreground too. if let newLocation {
locationManager.requestUpdate() location = newLocation
Task { await refresh() } 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 { private func refresh() async {
@@ -207,15 +156,22 @@ struct ContentView: View {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data." statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations() 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 { 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 cheapestPrice: Double?
let isTopResult: Bool let isTopResult: Bool
let isFavourite: Bool
var onToggleFavourite: () -> Void = {}
private var ragColor: Color { private var ragColor: Color {
guard let price = station.prices[fuel], let cheapestPrice else { return .gray } guard let price = station.prices[fuel], let cheapestPrice else { return .gray }
@@ -258,6 +214,7 @@ struct StationRow: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(station.name) Text(station.name)
.font(.headline) .font(.headline)
.lineLimit(1)
if isTopResult { if isTopResult {
Text("TOP") Text("TOP")
.font(.caption2.bold()) .font(.caption2.bold())
@@ -270,6 +227,7 @@ struct StationRow: View {
Text("\(station.address), \(station.postcode)") Text("\(station.address), \(station.postcode)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1)
if let location { if let location {
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng))) Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2) .font(.caption2)
@@ -293,8 +251,13 @@ struct StationRow: View {
} }
} }
} }
Image(systemName: "arrow.triangle.turn.up.right.circle") Button {
.foregroundStyle(.secondary) onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
+85
View File
@@ -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<String>
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")
}
}
}
+153
View File
@@ -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"
}
}
+129
View File
@@ -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<String>
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")
}
}
}
+58
View File
@@ -127,6 +127,9 @@ struct FuelStore {
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
static let sortModeKey = "fuelboard.sortMode" // SortMode raw value static let sortModeKey = "fuelboard.sortMode" // SortMode raw value
static let stationLimitKey = "fuelboard.stationLimit" // Int (10/25/50/75/100) 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 // MARK: Stations
@@ -204,6 +207,61 @@ struct FuelStore {
saveString(String(limit), service: stationLimitKey) 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 // MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? { private static func keychainData(service: String) -> Data? {