Files
fuelboard/Shared/FuelPriceWidgetViews.swift
FuelBoard Contributor 7c2eeb2604 Best Value: baseline on the closest eligible price and gate on freshness
- 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.
2026-10-15 21:37:24 +01:00

234 lines
9.8 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 | 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
}
}
}