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? {