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 stationLimit: Int = FuelStore.loadStationLimit() @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 displayedStations: [FuelStation] { Array(sortedStations.prefix(stationLimit)) } 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.") .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, 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. locationManager.requestUpdate() 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() } } } } } 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) { // Round brand logo (or generic fuel pump fallback) Group { if let asset = station.brandImageName { Image(asset) .resizable() .scaledToFit() .padding(5) } else { Image(systemName: "fuelpump.fill") .font(.system(size: 22)) .foregroundStyle(.white) } } .frame(width: 46, height: 46) .background(Circle().fill(.white)) .clipShape(Circle()) .overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1)) .shadow(color: .black.opacity(0.08), radius: 2, y: 1) 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. } }