165 lines
6.6 KiB
Swift
165 lines
6.6 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
|
|
// FuelBoard widget — cheapest petrol stations near you.
|
|
// systemMedium: top 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).
|
|
|
|
struct FuelPriceWidget: Widget {
|
|
let kind = "FuelPriceWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: FuelPriceProvider2()) { 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: (lat: Double, lng: Double)?
|
|
}
|
|
|
|
struct FuelPriceProvider2: 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)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
|
|
completion(makeEntry())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
|
|
let entry = makeEntry()
|
|
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
|
|
}
|
|
|
|
private func makeEntry() -> FuelPriceEntry {
|
|
let fuel = FuelStore.loadSelectedFuel()
|
|
let location = FuelStore.loadLocation().map { (lat: $0.lat, lng: $0.lng) }
|
|
var stations = FuelStore.loadStations()
|
|
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
|
let filtered = stations.filter { $0.prices[fuel] != nil }
|
|
let sorted: [FuelStation]
|
|
if let location {
|
|
sorted = filtered.sorted { lhs, rhs in
|
|
let lScore = lhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 + lhs.prices[fuel]! * 0.001
|
|
let rScore = rhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 + rhs.prices[fuel]! * 0.001
|
|
return lScore < rScore
|
|
}
|
|
} else {
|
|
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
|
}
|
|
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location)
|
|
}
|
|
}
|
|
|
|
struct FuelPriceWidgetView: View {
|
|
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 entry.stations.count == 1 || UIDevice.current.userInterfaceIdiom == .pad {
|
|
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)
|
|
}
|
|
Text("Tap for directions")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.widgetURL(station.mapsDirectionsURL)
|
|
}
|
|
|
|
private var stationList: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Image(systemName: "fuelpump.fill")
|
|
.foregroundStyle(.green)
|
|
Text("Cheapest \(entry.fuel.displayName)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("Tap → Maps")
|
|
.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] {
|
|
Text(String(format: "%.1fp", price))
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|