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:
@@ -24,6 +24,12 @@
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-119
@@ -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,136 +54,59 @@ 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 {
|
||||
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 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,
|
||||
TabView {
|
||||
StationsView(
|
||||
stations: displayedStations,
|
||||
totalCount: sortedStations.count,
|
||||
isLoading: isLoading,
|
||||
selectedFuel: $selectedFuel,
|
||||
sortMode: $sortMode,
|
||||
stationLimit: $stationLimit,
|
||||
cheapestPrice: cheapestPrice,
|
||||
isTopResult: index == 0
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
onToggleFavourite: toggleFavourite
|
||||
)
|
||||
}
|
||||
}
|
||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
FavouritesView(
|
||||
favourites: refreshedFavourites,
|
||||
selectedFuel: selectedFuel,
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
onToggleFavourite: toggleFavourite
|
||||
)
|
||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||
|
||||
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)
|
||||
}
|
||||
AlertsView(
|
||||
enabled: $alertsEnabled,
|
||||
radius: $alertsRadius,
|
||||
monitoredCount: monitor.monitoredStationIDs.count,
|
||||
lastAlert: monitor.lastAlert
|
||||
)
|
||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||
}
|
||||
.onAppear {
|
||||
// Request a fresh fix on every launch so "Closest" stays
|
||||
// accurate and the widget gets fresh coords.
|
||||
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 {
|
||||
// Re-request location on foreground too.
|
||||
locationManager.requestUpdate()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||
Task { await refresh() }
|
||||
}
|
||||
}
|
||||
@@ -191,7 +118,29 @@ struct ContentView: View {
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user