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/>
|
<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>
|
||||||
|
|||||||
@@ -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 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,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 {
|
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,
|
||||||
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,
|
|
||||||
cheapestPrice: cheapestPrice,
|
cheapestPrice: cheapestPrice,
|
||||||
isTopResult: index == 0
|
location: location,
|
||||||
|
favouriteIDs: favouriteIDs,
|
||||||
|
onToggleFavourite: toggleFavourite
|
||||||
)
|
)
|
||||||
}
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||||
}
|
|
||||||
|
|
||||||
if !displayedStations.isEmpty {
|
FavouritesView(
|
||||||
Divider()
|
favourites: refreshedFavourites,
|
||||||
Picker("Show", selection: $stationLimit) {
|
selectedFuel: selectedFuel,
|
||||||
ForEach([10, 25, 50, 75, 100], id: \.self) { count in
|
location: location,
|
||||||
Text("\(count)").tag(count)
|
favouriteIDs: favouriteIDs,
|
||||||
}
|
onToggleFavourite: toggleFavourite
|
||||||
}
|
)
|
||||||
.pickerStyle(.segmented)
|
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||||
.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") {
|
AlertsView(
|
||||||
HStack(spacing: 8) {
|
enabled: $alertsEnabled,
|
||||||
Circle().fill(.green).frame(width: 12, height: 12)
|
radius: $alertsRadius,
|
||||||
Text("Best value — within 1.5p of the cheapest")
|
monitoredCount: monitor.monitoredStationIDs.count,
|
||||||
.font(.caption)
|
lastAlert: monitor.lastAlert
|
||||||
}
|
)
|
||||||
HStack(spacing: 8) {
|
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||||
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 {
|
.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)
|
||||||
|
monitor.setEnabled(alertsEnabled)
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
// Re-request location on foreground too.
|
|
||||||
locationManager.requestUpdate()
|
locationManager.requestUpdate()
|
||||||
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
fuel: selectedFuel, radiusKM: alertsRadius)
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +118,29 @@ struct ContentView: View {
|
|||||||
Task { await refresh() }
|
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 {
|
||||||
|
|||||||
@@ -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 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? {
|
||||||
|
|||||||
Reference in New Issue
Block a user