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 } } }