- Widget tap-to-directions now uses the native maps:// scheme instead of http://maps.apple.com. The universal-link http URL failed to open from a widget and fell back to launching the containing app; maps:// opens Apple Maps directly for both small (whole widget) and medium (per row). - Onboarding: removed the Skip button (mandatory flow), and system prompts (location, notifications, local network) now fire only when the user taps Continue/Allow on each page — not when the page appears. Pages auto-advance once a permission is granted. - After 'Start Using FuelBoard' the first data fetch is forced, so a reinstall that leaves a recent lastRefresh in app-group defaults can no longer skip the fetch and leave the station list empty. 35 tests pass.
303 lines
13 KiB
Swift
303 lines
13 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import AppIntents
|
|
|
|
// FuelBoard widget — petrol stations near you.
|
|
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
|
|
// systemSmall: single station, whole widget opens Maps
|
|
// Taps deep-link to Apple Maps directions (maps://?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).
|
|
//
|
|
// Each widget instance is configured INDEPENDENTLY via its own App Intent
|
|
// (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort
|
|
// (Cheapest/Closest). Widgets no longer depend on the app's selected fuel —
|
|
// you can place several widgets showing different fuels/orders side by side.
|
|
//
|
|
// 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 {
|
|
AppIntentConfiguration(
|
|
kind: kind,
|
|
intent: FuelBoardWidgetConfigurationIntent.self,
|
|
provider: FuelPriceTimelineProvider()
|
|
) { entry in
|
|
FuelPriceWidgetView(entry: entry)
|
|
.containerBackground(for: .widget) {
|
|
Color(.systemBackground)
|
|
}
|
|
}
|
|
.configurationDisplayName("FuelBoard Prices")
|
|
.description("Fuel prices near you. Configure fuel + sort per widget.")
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|
|
|
|
struct FuelPriceEntry: TimelineEntry {
|
|
let date: Date
|
|
let stations: [FuelStation] // already filtered + sorted per config
|
|
let fuel: FuelType
|
|
let sort: SortMode // per-widget: cheapest | closest
|
|
let location: Coordinate?
|
|
let locationSource: String // "live" | "cached" | "none"
|
|
let unit: DistanceUnit // user's display unit for distances
|
|
}
|
|
|
|
struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
|
func placeholder(in context: Context) -> FuelPriceEntry {
|
|
let unit = FuelStore.loadDistanceUnit()
|
|
let sample = SampleFuelProvider.sampleStations
|
|
.filter { $0.prices[.e10] != nil }
|
|
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
|
return FuelPriceEntry(
|
|
date: Date(), stations: Array(sample.prefix(4)),
|
|
fuel: .e10, sort: .cheapest,
|
|
location: nil, locationSource: "none", unit: unit
|
|
)
|
|
}
|
|
|
|
func snapshot(for configuration: FuelBoardWidgetConfigurationIntent, in context: Context) async -> FuelPriceEntry {
|
|
await makeEntry(configuration: configuration)
|
|
}
|
|
|
|
func timeline(
|
|
for configuration: FuelBoardWidgetConfigurationIntent,
|
|
in context: Context
|
|
) async -> Timeline<FuelPriceEntry> {
|
|
let entry = await makeEntry(configuration: configuration)
|
|
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
return Timeline(entries: [entry], policy: .after(nextRefresh))
|
|
}
|
|
|
|
private func makeEntry(configuration: FuelBoardWidgetConfigurationIntent) async -> FuelPriceEntry {
|
|
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
|
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
|
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
|
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
|
|
|
// 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. The widget prefers its OWN focused fetch from the
|
|
// relay so it shows real prices even when the app-group cache isn't
|
|
// shared (SideStore free accounts don't provision the group, which
|
|
// previously left the widget stuck on sample data). Falls back to the
|
|
// shared cache, then to samples.
|
|
var stations: [FuelStation] = []
|
|
if let location {
|
|
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
|
stations = fetched
|
|
}
|
|
}
|
|
if stations.isEmpty { stations = FuelStore.loadStations() }
|
|
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
|
let unit = FuelStore.loadDistanceUnit()
|
|
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]
|
|
switch sort {
|
|
case .closest:
|
|
// Nearest first; price only breaks ties.
|
|
sorted = filtered.sorted { lhs, rhs in
|
|
if let location {
|
|
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
if lDist != rDist { return lDist < rDist }
|
|
}
|
|
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
|
}
|
|
case .cheapest:
|
|
// Cheapest first; distance only breaks ties.
|
|
sorted = filtered.sorted { lhs, rhs in
|
|
let lPrice = lhs.prices[fuel]!
|
|
let rPrice = rhs.prices[fuel]!
|
|
if lPrice != rPrice { return lPrice < rPrice }
|
|
if let location {
|
|
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
|
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
return FuelPriceEntry(
|
|
date: Date(), stations: Array(sorted.prefix(4)),
|
|
fuel: fuel, sort: sort,
|
|
location: location, locationSource: source,
|
|
unit: FuelStore.loadDistanceUnit()
|
|
)
|
|
}
|
|
|
|
/// One focused fetch from the relay around the widget's location — small
|
|
/// response (radius + limit 500), bounded so a dead relay can't stall the
|
|
/// timeline. Returns nil on any failure so callers fall back to cache.
|
|
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
|
|
var components = URLComponents(
|
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
|
resolvingAgainstBaseURL: false
|
|
)!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
|
URLQueryItem(name: "lat", value: String(location.lat)),
|
|
URLQueryItem(name: "lng", value: String(location.lng)),
|
|
URLQueryItem(name: "radius", value: String(radiusKM)),
|
|
URLQueryItem(name: "limit", value: "500"),
|
|
]
|
|
var request = URLRequest(url: components.url!)
|
|
request.timeoutInterval = 5
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
return try? FuelPriceProvider.decodeStations(from: data)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
singleStation
|
|
} else {
|
|
stationList
|
|
}
|
|
}
|
|
}
|
|
|
|
private var heading: String {
|
|
entry.sort == .closest
|
|
? "Closest \(entry.fuel.displayName)"
|
|
: "Cheapest \(entry.fuel.displayName)"
|
|
}
|
|
|
|
private var singleStation: some View {
|
|
let station = entry.stations[0]
|
|
return VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Image(systemName: "fuelpump.fill")
|
|
.foregroundStyle(.green)
|
|
Text(heading)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(station.brand.sanitizedStationTitle)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
if let price = station.prices[entry.fuel] {
|
|
Text(String(format: "£%.3f", price / 100))
|
|
.font(.system(size: 26, weight: .bold).monospaced())
|
|
.foregroundStyle(.green)
|
|
}
|
|
if let location = entry.location {
|
|
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
|
.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(heading)
|
|
.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: "maps://")!) {
|
|
HStack(spacing: 8) {
|
|
Text(station.brand.sanitizedStationTitle)
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(1)
|
|
if let location = entry.location {
|
|
Text(entry.unit.format(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: "£%.3f", price / 100))
|
|
.font(.caption.weight(.bold).monospaced())
|
|
.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
|
|
}
|
|
}
|
|
}
|