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
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
import CoreLocation
import WidgetKit
struct Coordinate: Equatable {
let lat: Double
let lng: Double
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var stations: [FuelStation] = []
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@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 sortedStations: [FuelStation] {
stations
.filter { $0.prices[selectedFuel] != nil }
.sorted { lhs, rhs in
// Prefer distance when we have a location, otherwise price.
guard let location else {
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
}
let lScore = lhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 +
lhs.prices[selectedFuel]! * 0.001
let rScore = rhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 +
rhs.prices[selectedFuel]! * 0.001
return lScore < rScore
}
}
var body: some View {
NavigationStack {
List {
Section {
Text("Cheapest \(selectedFuel.displayName) near you. 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") {
if isLoading {
HStack(spacing: 10) {
ProgressView()
Text("Fetching prices…")
}
} else if sortedStations.isEmpty {
Text("No \(selectedFuel.displayName) stations found.")
.foregroundStyle(.secondary)
} else {
ForEach(sortedStations) { station in
StationRow(station: station, fuel: selectedFuel, location: location)
}
}
}
Section("Location") {
if let location {
Text("Using location \(String(format: "%.4f, %.4f", location.lat, location.lng))")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("No location yet — allow location access to sort by distance.")
.font(.caption)
.foregroundStyle(.secondary)
}
Button("Update my location") {
locationManager.requestUpdate()
}
.font(.footnote)
}
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 {
Task { await refresh() }
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
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?
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(station.name)
.font(.headline)
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] {
Text(String(format: "%.1fp", price))
.font(.title3.bold())
.foregroundStyle(.green)
}
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.
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct FuelBoardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}