223 lines
10 KiB
Swift
223 lines
10 KiB
Swift
// 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` bounds the search area (used by the relay). Throws on failure
|
|
/// so callers can fall back to cached/sample data.
|
|
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)))
|
|
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
|
|
}
|
|
query.append(URLQueryItem(name: "limit", value: "500"))
|
|
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.
|
|
var allPrices: [FuelType: Double] {
|
|
var result: [FuelType: Double] = [:]
|
|
if let prices {
|
|
for (grade, value) in prices {
|
|
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 {
|
|
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 <token>` 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."
|
|
}
|
|
}
|
|
}
|