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,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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user