Distance confidence - Every distance surface takes the figure AND its "Approx." marker from one FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget, Live Activity, watch, Siri), so a surface can never mark one figure approximate while showing a routed one. Radius filters and Closest ordering test that same displayed distance, so no row can read a distance outside the radius that filtered it (the reported "14.6 mi inside a 10-mile list"). Accurate distances (Settings -> Distances, default off) - Off: the browse layer serves the straight line, marked Approx., for every row, so two installs agree to the metre and the app makes no route requests at all. - On: routed distances for the whole search area, unmarked, served only from a COMPLETE set anchored within 600 m — a partial fill reverts every row at once rather than mixing road and straight-line bases. - Best Value maths sits deliberately outside the toggle: it keeps using measured road routes and its own confidence marking, so money figures never move with the switch. - Fills run only while the toggle is on and the app is frontmost; capacity raised to 600 stations per pass; completeness tolerates pins Apple Maps has no route for, which otherwise left an area permanently incomplete and the whole list stuck on estimates. Performance (the reported slowdown switching to Closest / launching with it) - FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per Closest sort. Memoised, with the setter keeping it in step. - Both keyed sorts decorate their keys — one distance per station, carried as an index instead of copying whole stations into the decorated array — and the comparison baseline plus per-station journeys now come from ONE pass instead of two full scans per tab. The Stations tab also reuses its single sorted list for the count and the TOP badge instead of sorting the country three times. - Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per list, Cheapest 6 ms, Best Value 11 ms. - DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where devicectl eats a leading-dash argument. Tests: 198, 0 failures. Device and simulator Debug builds green.
195 lines
7.7 KiB
Swift
195 lines
7.7 KiB
Swift
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 {
|
||
let distance = FuelStore.stationDistanceDisplay(
|
||
station: station, userLat: location.lat, userLng: location.lng
|
||
)
|
||
Text(distance.label(unit: entry.unit) + " away")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
Text(FuelStore.loadStationTapAction() == .showDetails
|
||
? "Tap for details" : "Tap for directions")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : 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.tapDestinationURL ?? 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 {
|
||
let distance = FuelStore.stationDistanceDisplay(
|
||
station: station, userLat: location.lat, userLng: location.lng
|
||
)
|
||
Text(distance.label(unit: entry.unit))
|
||
.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
|
||
}
|
||
}
|
||
}
|