290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
import WidgetKit
|
|
|
|
struct ContentView: View {
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
@State private var stations: [FuelStation] = []
|
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
|
@State private var location: Coordinate? = {
|
|
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
|
|
return nil
|
|
}()
|
|
@State private var isLoading = false
|
|
@State private var statusMessage = ""
|
|
@State private var locationManager = LocationManager()
|
|
|
|
private var cheapestPrice: Double? {
|
|
stations.compactMap { $0.prices[selectedFuel] }.min()
|
|
}
|
|
|
|
private var sortedStations: [FuelStation] {
|
|
let available = stations.filter { $0.prices[selectedFuel] != nil }
|
|
switch sortMode {
|
|
case .closest:
|
|
guard let location else { return available.sorted { $0.prices[selectedFuel]! < $1.prices[selectedFuel]! } }
|
|
return available.sorted { lhs, rhs in
|
|
// Closest first; price only breaks ties.
|
|
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
if lDist != rDist { return lDist < rDist }
|
|
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
|
|
}
|
|
case .cheapest:
|
|
return available.sorted { lhs, rhs in
|
|
// Cheapest first; distance only breaks ties.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions. 🟢 great value · 🟡 okay · 🔴 pricey")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Section("Sort by") {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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") {
|
|
if isLoading {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text("Fetching prices…")
|
|
}
|
|
} else if sortedStations.isEmpty {
|
|
Text("No \(selectedFuel.displayName) stations found.")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(Array(sortedStations.enumerated()), id: \.element.id) { index, station in
|
|
StationRow(
|
|
station: station,
|
|
fuel: selectedFuel,
|
|
location: location,
|
|
cheapestPrice: cheapestPrice,
|
|
isTopResult: index == 0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Location") {
|
|
if let location {
|
|
Text("Using location \(String(format: "%.4f, %.4f", location.lat, location.lng))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("No location yet — allow location access to sort by distance.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Button("Update my location") {
|
|
locationManager.requestUpdate()
|
|
}
|
|
.font(.footnote)
|
|
}
|
|
|
|
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 {
|
|
// Auto-request location on first launch so the widget gets a
|
|
// fresh fix without the user hunting for the button.
|
|
if FuelStore.loadLocation() == nil {
|
|
locationManager.requestUpdate()
|
|
}
|
|
Task { await refresh() }
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
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() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refresh() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
do {
|
|
let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel)
|
|
stations = fetched
|
|
FuelStore.saveStations(fetched)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
|
} catch {
|
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
|
|
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StationRow: View {
|
|
let station: FuelStation
|
|
let fuel: FuelType
|
|
let location: Coordinate?
|
|
let cheapestPrice: Double?
|
|
let isTopResult: Bool
|
|
|
|
private var ragColor: Color {
|
|
guard let price = station.prices[fuel], let cheapestPrice else { return .gray }
|
|
switch RAGRating.rating(price: price, cheapest: cheapestPrice) {
|
|
case .green: return .green
|
|
case .amber: return .orange
|
|
case .red: return .red
|
|
}
|
|
}
|
|
|
|
private var deltaText: String? {
|
|
guard let price = station.prices[fuel], let cheapestPrice else { return nil }
|
|
let delta = price - cheapestPrice
|
|
if delta <= 0.05 { return "best" }
|
|
return String(format: "+%.1fp", delta)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(station.name)
|
|
.font(.headline)
|
|
if isTopResult {
|
|
Text("TOP")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
Text("\(station.address), \(station.postcode)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if let location {
|
|
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
if let price = station.prices[fuel] {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(ragColor)
|
|
.frame(width: 10, height: 10)
|
|
Text(String(format: "%.1fp", price))
|
|
.font(.title3.bold())
|
|
}
|
|
if let deltaText {
|
|
Text(deltaText)
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(ragColor)
|
|
}
|
|
}
|
|
}
|
|
Image(systemName: "arrow.triangle.turn.up.right.circle")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if let url = station.mapsDirectionsURL {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Location manager
|
|
|
|
@MainActor
|
|
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
|
@Published var current: Coordinate?
|
|
private let manager = CLLocationManager()
|
|
|
|
override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
}
|
|
|
|
func requestUpdate() {
|
|
switch manager.authorizationStatus {
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
manager.requestLocation()
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
manager.requestLocation()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
guard let loc = locations.last else { return }
|
|
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Ignore — the app still works price-sorted without location.
|
|
}
|
|
}
|