- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared comparison confidence, so a saving is presented as verified only when both the candidate and the baseline pass the gate for the fuel on screen. - Rank against the closest station with an eligible selected-fuel price instead of the physically closest station, so a single stale neighbour can no longer invalidate every comparison in the list. Ordinary Closest sorting still includes every priced station. - Keep ineligible prices visible, fall back to pump-price order, and label Best Value unavailable when no comparison qualifies. - Widget: same baseline and confidence path as the app. - Tests: stale-closest baseline, alert baseline, eligibility policy, boundary and missing-timestamp regressions.
234 lines
9.8 KiB
Swift
234 lines
9.8 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 | Best Value
|
||
let isFavourites: Bool // favourites mode: pinned stations
|
||
let location: Coordinate?
|
||
let locationSource: String // "live" | "cached" | "none"
|
||
let unit: DistanceUnit // user's display unit for distances
|
||
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
||
/// Both selected-fuel prices are assessed by the shared freshness policy.
|
||
var bestValuePriceConfidence: FuelPriceFreshness.ComparisonConfidence = .ineligible
|
||
/// Candidate-aware warning copy (names the fuel, and the baseline when the
|
||
/// baseline is the weak link). Falls back to the confidence's own text.
|
||
var bestValuePriceWarning: String? = nil
|
||
}
|
||
|
||
/// 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)" }
|
||
switch entry.sort {
|
||
case .closest: return "Closest \(entry.fuel.displayName)"
|
||
case .worthTheDrive: return "Best Value \(entry.fuel.displayName)"
|
||
case .cheapest: return "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 entry.sort == .worthTheDrive, let result = entry.bestValueResult {
|
||
Text(result.headline(compact: true))
|
||
.font(.title3.weight(.bold))
|
||
.lineLimit(1)
|
||
Text(result.status == .baseline ? "your baseline" : "vs closest eligible station")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
if let warning = entry.bestValuePriceWarning ?? entry.bestValuePriceConfidence.warningText {
|
||
Text(warning)
|
||
.font(.caption2)
|
||
.foregroundStyle(.orange)
|
||
.lineLimit(2)
|
||
}
|
||
} else if entry.sort == .worthTheDrive {
|
||
if let price = station.prices[entry.fuel] {
|
||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||
}
|
||
if entry.location == nil {
|
||
Text("Best Value unavailable · location needed")
|
||
.font(.caption2)
|
||
.foregroundStyle(.orange)
|
||
.lineLimit(2)
|
||
} else {
|
||
Text("Best Value unavailable · price confidence insufficient")
|
||
.font(.caption2)
|
||
.foregroundStyle(.orange)
|
||
.lineLimit(2)
|
||
}
|
||
} else 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.sort == .worthTheDrive && entry.bestValuePriceConfidence == .ineligible
|
||
? "price order · Best Value unavailable"
|
||
: entry.isFavourites ? "your order"
|
||
: (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
|
||
}
|
||
}
|
||
}
|