Small widgetURL and medium list Links route through StationTapAction via FuelStation.tapDestinationURL: Open map keeps the Maps URL, More info uses a fuelboard://station deep link (stationID plus coordinate/name fallback). Deep link carries fallback coords; ContentView presents the detail sheet when the station is cached, else the map sheet. Live Activity detailURL upgraded to the same rich link.
188 lines
7.4 KiB
Swift
188 lines
7.4 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 {
|
||
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.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 {
|
||
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
|
||
}
|
||
}
|
||
}
|