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:
+99
-136
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user