// FuelPriceProvider.swift — data-source seam for FuelBoard. // // The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live // source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0 // client credentials. For the scaffold we ship SampleFuelProvider (realistic // stations around a location, works offline), plus the FuelFinderProvider // skeleton with the exact integration steps documented inline so wiring the // live API later is a drop-in: change `activeProvider` to `.fuelFinder`. import Foundation // MARK: - Provider protocol protocol FuelPriceProviding { /// Fetch stations with prices. `location` may be nil (sort by price only). /// `radiusKM` nil = full dataset (device filters); set = focused server-side /// radius (alert path). Throws on failure so callers can fall back. func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] } enum FuelPriceProvider { /// Default: the keyless relay (full-UK Fuel Finder data). Falls back to /// the England-wide sample set when the relay is unreachable. static let active: FuelPriceProviding = RelayFuelProvider() } // MARK: - Relay provider (default) /// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK /// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and /// filters/sorts server-side. No credentials in the app. struct RelayFuelProvider: FuelPriceProviding { var baseURL = URL(string: "http://192.168.1.131:8788")! func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)! var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)] if let lat, let lng { query.append(URLQueryItem(name: "lat", value: String(lat))) query.append(URLQueryItem(name: "lng", value: String(lng))) } if let radiusKM { // Focused fetch (alert path) — small response, server-side radius. query.append(URLQueryItem(name: "radius", value: String(radiusKM))) query.append(URLQueryItem(name: "limit", value: "500")) } else { // Full-UK dump: NO radius — the device filters locally // (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset. query.append(URLQueryItem(name: "limit", value: "10000")) } components.queryItems = query let (data, response) = try await URLSession.shared.data(from: components.url!) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw FuelProviderError.relayUnavailable } let payload = try JSONDecoder().decode(RelayResponse.self, from: data) return payload.stations.map { relay in FuelStation( id: relay.id ?? relay.name ?? UUID().uuidString, name: (relay.name ?? "Unknown").sanitizedStationTitle, brand: relay.brand ?? "", address: relay.address ?? "", postcode: relay.postcode ?? "", lat: relay.lat ?? 0, lng: relay.lng ?? 0, prices: relay.allPrices, priceUpdated: nil ) } } } private struct RelayResponse: Codable { struct RelayStation: Codable { let id: String? let name: String? let brand: String? let address: String? let postcode: String? let lat: Double? let lng: Double? let price: Double? let prices: [String: Double]? /// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the /// station sells — one fetch populates every fuel tab. Defensive guard: /// any grade outside the 50–500 pence/litre band is dropped, so a relay /// regression (e.g. the band being removed server-side) can't re-poison /// the nationwide cheapest reference with 1.3p / 1589p garbage. var allPrices: [FuelType: Double] { var result: [FuelType: Double] = [:] if let prices { for (grade, value) in prices { guard (50...500).contains(value) else { continue } switch grade.uppercased() { case "E10": result[.e10] = value case "E5": result[.e5] = value case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value default: break } } } // Backwards-compat: relay versions without `prices` still send `price`. if result.isEmpty, let price, (50...500).contains(price) { result[.e10] = price } return result } } let stations: [RelayStation] } // MARK: - Sample provider (offline fallback only) /// Representative stations spread across ENGLAND (one cluster per region) so /// the fallback works anywhere in the country — it is NOT tied to one town. /// Real England-wide data comes from the relay (full-UK Fuel Finder CSV/API). /// Prices in pence/litre. struct SampleFuelProvider: FuelPriceProviding { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch return Self.sampleStations } static let sampleStations: [FuelStation] = [ // Yorkshire & the Humber (Halifax area) FuelStation(id: "y1", name: "Morrisons Halifax", brand: "Morrisons", address: "Haugh Shaw Road", postcode: "HX1 3TU", lat: 53.7265, lng: -1.8580, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil), FuelStation(id: "y2", name: "Asda Leeds Crown Point", brand: "Asda", address: "Crown Point Road", postcode: "LS10 1ET", lat: 53.7800, lng: -1.5300, prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], priceUpdated: nil), FuelStation(id: "y3", name: "Tesco Express York", brand: "Tesco", address: "Fulford Road", postcode: "YO10 4AB", lat: 53.9500, lng: -1.0800, prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9], priceUpdated: nil), // North West FuelStation(id: "nw1", name: "Sainsbury's Manchester", brand: "Sainsbury's", address: "Regent Road", postcode: "M5 3QH", lat: 53.4700, lng: -2.2800, prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9], priceUpdated: nil), FuelStation(id: "nw2", name: "Shell Liverpool", brand: "Shell", address: "Rice Lane", postcode: "L9 1AD", lat: 53.4400, lng: -2.9600, prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9], priceUpdated: nil), // Midlands FuelStation(id: "m1", name: "Morrisons Birmingham", brand: "Morrisons", address: "Hagley Road", postcode: "B16 8NA", lat: 52.4700, lng: -1.9500, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil), FuelStation(id: "m2", name: "Tesco Express Nottingham", brand: "Tesco", address: "Mansfield Road", postcode: "NG5 2DP", lat: 52.9900, lng: -1.1500, prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9], priceUpdated: nil), // South East / London FuelStation(id: "se1", name: "Asda Wembley", brand: "Asda", address: "Great Central Way", postcode: "HA9 0DB", lat: 51.5500, lng: -0.2800, prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9], priceUpdated: nil), FuelStation(id: "se2", name: "BP Brighton", brand: "BP", address: "Lewes Road", postcode: "BN2 3QB", lat: 50.8300, lng: -0.1100, prices: [.e10: 140.9, .diesel: 147.9], priceUpdated: nil), // South West FuelStation(id: "sw1", name: "Esso Bristol", brand: "Esso", address: "Wells Road", postcode: "BS4 2PP", lat: 51.4400, lng: -2.5700, prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9], priceUpdated: nil), // North East FuelStation(id: "ne1", name: "Gulf Newcastle", brand: "Gulf", address: "Scotswood Road", postcode: "NE4 7AA", lat: 54.9700, lng: -1.6400, prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9], priceUpdated: nil), ] } // MARK: - Fuel Finder API provider (integration point) /// Live provider skeleton for the official GOV.UK Fuel Finder API. /// /// To activate: /// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login, /// register an app, and generate client credentials (client_id + secret). /// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0 /// client_credentials grant → access_token (expires in 1h). /// 3. GET prices with `Authorization: Bearer ` and decode the /// stations JSON into [FuelStation]. /// /// Endpoints (per gov.uk docs): /// base: https://api.fuelfinder.service.gov.uk /// auth: base + /v1/token /// prices: base + /v1/prices (full snapshot) /// price: base + /v1/prices/GB-12345 (single station) /// /// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`. struct FuelFinderProvider: FuelPriceProviding { let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")! let clientID: String let clientSecret: String func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { // TODO: OAuth token → GET /v1/prices → map to FuelStation. // The live API requires authentication; see notes above. throw FuelProviderError.notImplemented } } enum FuelProviderError: LocalizedError { case notImplemented case relayUnavailable var errorDescription: String? { switch self { case .notImplemented: return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." case .relayUnavailable: return "FuelBoard Relay unreachable — showing cached/sample data." } } }