Files
fuelboard/Shared/FuelPriceWidgetViews.swift
FuelBoard Contributor 64ef260959 widget + live activity: prefer Apple-Maps road distance, computed by the app
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:

- New RoadDistanceService (app target): for the nearest 12 stations each
  pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
  and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
  even on free SideStore accounts with no app-group container; only valid
  within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
  wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
  distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
2026-08-20 11:33:47 +01:00

188 lines
7.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import WidgetKit
// Shared widget rendering — compiled by BOTH the widget extension and the
// app target. The app uses it for the in-app widget preview screen (launch
// arg `-widgets`); the widget extension uses it for real timelines.
// WidgetKit is available to the app target, so WidgetFamily is fine here.
// NOTE: FuelBoardTests only symlinks specific Shared files, so this file is
// NOT compiled by the SPM test target (it needs WidgetKit).
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
// (#FFD60A), cyan = diesel (#64D2FF). Lives here because both the widget and
// the app preview need it; Shared/FuelStore.swift stays Foundation-only.
// (Previously a private extension in the widget target — deleted on the move.)
private extension FuelType {
var fuelColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
/// Plain data (no WidgetKit types) so both the widget and the app preview can
/// construct entries. The widget's `FuelPriceEntry: TimelineEntry` wraps this.
struct FuelPriceWidgetEntryData {
let date: Date
let stations: [FuelStation] // already filtered + sorted per config
let fuel: FuelType
let sort: SortMode // per-widget: cheapest | closest
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
let location: Coordinate?
let locationSource: String // "live" | "cached" | "none"
let unit: DistanceUnit // user's display unit for distances
}
/// The widget face, parameterised by family so the app can preview it.
/// `isWidget` controls WidgetKit-only chrome (widgetURL / Link rows) — in-app
/// previews pass false.
struct FuelPriceWidgetContent: View {
let entry: FuelPriceWidgetEntryData
let family: WidgetFamily
var isWidget = true
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: entry.isFavourites ? "star" : "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text(entry.isFavourites
? "No favourite \(entry.fuel.displayName) stations"
: "No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if family == .systemSmall {
singleStation
} else {
stationList
}
}
}
private var heading: String {
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
return 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(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.name)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
}
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
}
/// How many station rows each family can fit: medium widgets are short
/// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height
/// and fit 5 compact rows. Small widgets use singleStation instead.
private var maxRows: Int {
switch family {
case .systemMedium: return 3
default: return 5
}
}
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(entry.fuel.fuelColor)
Text(heading)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(entry.isFavourites ? "cheapest first"
: (entry.locationSource == "none" ? "by price" : "near you"))
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(maxRows)) { station in
if isWidget {
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
row(station, cheapest: cheapest)
}
.buttonStyle(.plain)
} else {
row(station, cheapest: cheapest)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ station: FuelStation, cheapest: Double?) -> some View {
HStack(spacing: 8) {
Text(station.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: 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)
FuelStore.priceTextAttributed(price, size: 12, weight: .bold)
}
}
}
}
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
}
}
}
/// Applies widgetURL only when a URL is given (WidgetKit-only modifier).
private struct WidgetURLModifier: ViewModifier {
let url: URL?
func body(content: Content) -> some View {
if let url {
content.widgetURL(url)
} else {
content
}
}
}