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
+99 -136
View File
@@ -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<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)
}
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 {