Files
fuelboard/FuelBoardWidgets/FuelPriceWidget.swift
T

228 lines
9.2 KiB
Swift

import WidgetKit
import SwiftUI
// FuelBoard widget — cheapest petrol stations near you.
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
// systemSmall: single cheapest station, whole widget opens Maps
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=).
// Note: on the home screen Link opens Maps directly. Inside CarPlay the widget
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
//
// Location flow: the provider requests location itself (async, timeout-bounded).
// On success it caches the fix in the shared store for the app; on failure
// (no permission yet / timeout) it falls back to the app's cached location,
// then to price-only sorting. So the widget is location-driven the moment the
// app has been opened once and permission granted.
struct FuelPriceWidget: Widget {
let kind = "FuelPriceWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.configurationDisplayName("FuelBoard Prices")
.description("The cheapest fuel near you. Tap a station for directions.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct FuelPriceEntry: TimelineEntry {
let date: Date
let stations: [FuelStation] // already filtered + sorted for the selected fuel
let fuel: FuelType
let location: Coordinate?
let locationSource: String // "live" | "cached" | "none"
}
struct FuelPriceTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
let sample = SampleFuelProvider.sampleStations
.filter { $0.prices[fuel] != nil }
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none")
}
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
Task {
completion(await makeEntry())
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
Task {
let entry = await makeEntry()
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
}
}
private func makeEntry() async -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
// 1) Try a fresh location fix (bounded to a few seconds).
var location = await WidgetLocationFetcher.shared.currentLocation().map {
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
}
var source = "live"
// 2) Fall back to the app's cached fix.
if location == nil, let cached = FuelStore.loadLocation() {
location = cached
source = "cached"
}
if location == nil {
source = "none"
}
// Persist a live fix so the app shows fresh coords on next launch.
if let location {
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
}
// 3) Load stations, then sort by price (distance tiebreak), scoped to
// the chosen search radius (miles) so the widget's "cheapest" matches
// the app's list.
var stations = FuelStore.loadStations()
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
let radiusKM = Double(FuelStore.loadStationLimit()) * 1.60934 // miles → km
if let location {
// STRICT: cached data fetched around another location must never
// leak out-of-radius stations into the widget.
stations = stations.filter {
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
}
}
let filtered = stations.filter { $0.prices[fuel] != nil }
let sorted: [FuelStation]
if let location {
sorted = filtered.sorted { lhs, rhs in
// Cheapest first; distance only breaks ties.
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
rhs.distanceKM(to: location.lat, lng2: location.lng)
}
} else {
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
}
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source)
}
}
struct FuelPriceWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: FuelPriceEntry
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text("No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if family == .systemSmall {
singleCheapest
} else {
stationList
}
}
}
private var singleCheapest: some View {
let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.brand)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
Text(String(format: "%.1fp", price))
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.green)
}
if let location = entry.location {
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(station.mapsDirectionsURL)
}
private var stationList: some View {
let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min()
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.locationSource == "none" ? "by price" : "near you")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(3)) { station in
Link(destination: station.mapsDirectionsURL ?? URL(string: "http://maps.apple.com")!) {
HStack(spacing: 8) {
Text(station.brand)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(String(format: "%.1f km", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if let price = station.prices[entry.fuel] {
HStack(spacing: 4) {
Circle()
.fill(ragColor(for: price, cheapest: cheapest))
.frame(width: 6, height: 6)
Text(String(format: "%.1fp", price))
.font(.caption.weight(.bold))
.foregroundStyle(.primary)
}
}
}
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func ragColor(for price: Double, cheapest: Double?) -> Color {
guard let cheapest else { return .gray }
switch RAGRating.rating(price: price, cheapest: cheapest) {
case .green: return .green
case .amber: return .orange
case .red: return .red
}
}
}