Scaffold FuelBoard: petrol price widget + app (sample data, Maps deep-links, provider seam for Fuel Finder API)

This commit is contained in:
FuelBoard Contributor
2026-08-11 13:54:56 +01:00
commit b0a5b5a326
12 changed files with 1191 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
// 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).
/// Throws on failure so callers can fall back to cached/sample data.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation]
}
enum FuelPriceProvider {
static let active: FuelPriceProviding = SampleFuelProvider()
}
// MARK: - Sample provider (default for the scaffold)
/// Ships realistic stations around Cambridge (52.2053, 0.1218) so the app and
/// widget render meaningful data with zero setup. Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] {
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
return Self.sampleStations
}
static let sampleStations: [FuelStation] = [
FuelStation(id: "s1", name: "Shell Cambridge Retail Park", brand: "Shell",
address: "12 Retail Park Way", postcode: "CB1 3EW",
lat: 52.1955, lng: 0.1380,
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
priceUpdated: nil),
FuelStation(id: "s2", name: "Tesco Express Hills Road", brand: "Tesco",
address: "245 Hills Road", postcode: "CB2 8RP",
lat: 52.1809, lng: 0.1398,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
FuelStation(id: "s3", name: "BP Milton Road", brand: "BP",
address: "161 Milton Road", postcode: "CB4 1XE",
lat: 52.2160, lng: 0.1410,
prices: [.e10: 140.9, .diesel: 147.9],
priceUpdated: nil),
FuelStation(id: "s4", name: "Morrisons Newmarket Road", brand: "Morrisons",
address: "Newmarket Road", postcode: "CB5 8AA",
lat: 52.2164, lng: 0.1599,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "s5", name: "Sainsbury's Coldhams Lane", brand: "Sainsbury's",
address: "Coldhams Lane", postcode: "CB1 3HY",
lat: 52.2025, lng: 0.1589,
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
priceUpdated: nil),
FuelStation(id: "s6", name: "Esso Cherry Hinton", brand: "Esso",
address: "Cherry Hinton Road", postcode: "CB1 9AP",
lat: 52.1854, lng: 0.1657,
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
priceUpdated: nil),
FuelStation(id: "s7", name: "Gulf Fen Road", brand: "Gulf",
address: "Fen Road", postcode: "CB4 1UN",
lat: 52.2201, lng: 0.1470,
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
priceUpdated: nil),
FuelStation(id: "s8", name: "Asda Beehive Centre", brand: "Asda",
address: "Coldhams Lane", postcode: "CB1 3ER",
lat: 52.1995, lng: 0.1641,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.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) 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
var errorDescription: String? { "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." }
}
+165
View File
@@ -0,0 +1,165 @@
// FuelBoard shared model compiled into BOTH the app and the widget extension.
// Kept Foundation-only so both targets can compile it (no SwiftUI dependency).
//
// Storage strategy (same as WidgetBoard proven on free SideStore accounts):
// App-group UserDefaults + Keychain (generic password). Keychain first:
// shared group $(AppIdentifierPrefix)com.apt.fuelboard.shared is listed FIRST
// in the entitlements so it is the default access group. Read order:
// keychain app-group defaults fallback.
import Foundation
import Security
// MARK: - Fuel types
enum FuelType: String, Codable, CaseIterable, Identifiable {
case e10 // Unleaded 95 (E10)
case e5 // Premium 97/98 (E5)
case diesel // B7 diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "E10 Unleaded"
case .e5: return "E5 Premium"
case .diesel: return "Diesel"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
let id: String
let name: String
let brand: String
let address: String
let postcode: String
let lat: Double
let lng: Double
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
let prices: [FuelType: Double]
/// Updated timestamp (seconds since 1970) nil for sample data.
let priceUpdated: TimeInterval?
/// Cheap haversine distance to a location, in km.
func distanceKM(to lat2: Double, lng2: Double) -> Double {
let r = 6371.0
let dLat = (lat2 - lat) * .pi / 180
let dLng = (lng2 - lng) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// Apple Maps directions URL used by the widget tap and app rows.
var mapsDirectionsURL: URL? {
URL(string: "http://maps.apple.com/?daddr=\(lat),\(lng)&t=d")
}
}
// MARK: - Shared store
struct FuelStore {
static let appGroupSuite = "group.com.apt.fuelboard"
static let stationsKey = "fuelboard.stations" // [FuelStation] JSON
static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp"
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
// MARK: Stations
static func loadStations() -> [FuelStation] {
if let data = keychainData(service: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations
}
if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations
}
return []
}
static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
writeKeychain(data: data, service: stationsKey)
}
}
// MARK: Last known location ("lat,lng,unixTime")
static func loadLocation() -> (lat: Double, lng: Double, date: Date)? {
let raw = loadString(service: locationKey)
let parts = raw?.split(separator: ",").compactMap { Double($0) }
guard let parts, parts.count == 3 else { return nil }
return (parts[0], parts[1], Date(timeIntervalSince1970: parts[2]))
}
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
}
// MARK: Selected fuel
static func loadSelectedFuel() -> FuelType {
if let raw = loadString(service: fuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveSelectedFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: fuelKey)
}
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return data
}
private static func writeKeychain(data: Data, service: String) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data,
]
SecItemAdd(addQuery as CFDictionary, nil)
}
private static func loadString(service: String) -> String? {
if let data = keychainData(service: service) {
return String(data: data, encoding: .utf8)
}
if let defaults = UserDefaults(suiteName: appGroupSuite) {
return defaults.string(forKey: service)
}
return nil
}
private static func saveString(_ value: String, service: String) {
UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service)
writeKeychain(data: Data(value.utf8), service: service)
}
}