Scaffold FuelBoard: petrol price widget + app (sample data, Maps deep-links, provider seam for Fuel Finder API)
This commit is contained in:
@@ -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." }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user