Widget preview screen + shared widget rendering; dark-mode screenshots with real data
- Move widget entry view into Shared/FuelPriceWidgetViews.swift (FuelPriceWidgetContent, FuelPriceWidgetEntryData) so the app can render the REAL widget faces; widget extension now wraps it (FuelPriceEntry.data) — one source of truth for the face - WidgetMockScreen (-widgets launch arg): app icon + small + medium widget previews for App Store captures; AppIconPreview image set (1024 drip master) - AppStoreScreenshots: 5-8 dark-mode tab captures with real Halifax data, 9 widgets - 95/95 tests green; Release IPA rebuilt + re-served
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 516 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIconPreview.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ struct ContentView: View {
|
|||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
@State private var showOnboarding = false
|
@State private var showOnboarding = false
|
||||||
|
@State private var showWidgetMock = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
@StateObject private var monitor = ProximityMonitor()
|
@StateObject private var monitor = ProximityMonitor()
|
||||||
@@ -172,6 +173,9 @@ struct ContentView: View {
|
|||||||
showOnboarding = false
|
showOnboarding = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showWidgetMock) {
|
||||||
|
WidgetMockScreen()
|
||||||
|
}
|
||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
StationMapView(request: request)
|
StationMapView(request: request)
|
||||||
}
|
}
|
||||||
@@ -181,6 +185,7 @@ struct ContentView: View {
|
|||||||
// opens the given tab; `-skipOnboarding` skips onboarding
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||||
// without touching the stored flag.
|
// without touching the stored flag.
|
||||||
let args = ProcessInfo.processInfo.arguments
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
showWidgetMock = args.contains("-widgets")
|
||||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||||
switch args[i + 1] {
|
switch args[i + 1] {
|
||||||
case "favourites": selectedTab = 1
|
case "favourites": selectedTab = 1
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
/// Full-screen widget preview (launch arg `-widgets`): the real app icon
|
||||||
|
/// plus the REAL widget faces (Shared/FuelPriceWidgetViews.swift — the same
|
||||||
|
/// code the widget extension renders) at small + medium sizes. Used for App
|
||||||
|
/// Store screenshot captures; not reachable from the UI.
|
||||||
|
struct WidgetMockScreen: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.09, green: 0.10, blue: 0.14),
|
||||||
|
Color(red: 0.02, green: 0.03, blue: 0.06)
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
|
// App icon + name, home-screen style
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image("AppIconPreview")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 76, height: 76)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("FuelBoard")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("Cheapest fuel near you")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
widgetCard(.systemSmall, label: "Small widget")
|
||||||
|
widgetCard(.systemMedium, label: "Medium widget")
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func widgetCard(_ family: WidgetFamily, label: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
FuelPriceWidgetContent(entry: mockEntry(family: family), family: family, isWidget: false)
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real stations (loaded dump), cheapest-first for the chosen fuel — the
|
||||||
|
/// same selection the widget's own provider makes.
|
||||||
|
private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData {
|
||||||
|
let fuel = FuelType.e10
|
||||||
|
let all = FuelStore.loadStations()
|
||||||
|
let selling = all.filter { $0.prices[fuel] != nil }
|
||||||
|
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||||
|
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||||
|
let count = family == .systemSmall ? 1 : 3
|
||||||
|
return FuelPriceWidgetEntryData(
|
||||||
|
date: Date(),
|
||||||
|
stations: Array(selling.prefix(count)),
|
||||||
|
fuel: fuel,
|
||||||
|
sort: .cheapest,
|
||||||
|
isFavourites: false,
|
||||||
|
location: location,
|
||||||
|
locationSource: location != nil ? "live" : "none",
|
||||||
|
unit: FuelStore.loadDistanceUnit()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,9 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
|
// Fuel colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from
|
||||||
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
|
// here 2026-08-16 so the app's widget preview screen uses the same colours).
|
||||||
// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because
|
// Shared/FuelStore.swift stays Foundation-only by design.
|
||||||
// Shared/FuelStore.swift is Foundation-only by design; if the app's copy
|
|
||||||
// ever moves to Shared, DELETE this duplicate to avoid redeclaration.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
|
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
|
||||||
// .systemSmall: single station (the first of the configured result — the
|
// .systemSmall: single station (the first of the configured result — the
|
||||||
@@ -84,14 +73,8 @@ struct FuelPriceWidget: Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceEntry: TimelineEntry {
|
struct FuelPriceEntry: TimelineEntry {
|
||||||
let date: Date
|
let data: FuelPriceWidgetEntryData
|
||||||
let stations: [FuelStation] // already filtered + sorted per config
|
var date: Date { data.date }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
||||||
@@ -109,9 +92,11 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
.filter { $0.prices[.e10] != nil }
|
.filter { $0.prices[.e10] != nil }
|
||||||
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
||||||
return FuelPriceEntry(
|
return FuelPriceEntry(
|
||||||
date: Date(), stations: Array(sample.prefix(8)),
|
data: FuelPriceWidgetEntryData(
|
||||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
date: Date(), stations: Array(sample.prefix(8)),
|
||||||
location: nil, locationSource: "none", unit: unit
|
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||||
|
location: nil, locationSource: "none", unit: unit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +127,14 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
/// extension and what it produced. Deliberately outside the timeline
|
/// extension and what it produced. Deliberately outside the timeline
|
||||||
/// result — can never affect rendering.
|
/// result — can never affect rendering.
|
||||||
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
||||||
|
let d = entry.data
|
||||||
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
||||||
let first = entry.stations.first
|
let first = d.stations.first
|
||||||
let json = """
|
let json = """
|
||||||
{"intent":"\(intentType)","source":"\(entry.locationSource)",\
|
{"intent":"\(intentType)","source":"\(d.locationSource)",\
|
||||||
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
|
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
|
||||||
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
|
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
|
||||||
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
|
||||||
"""
|
"""
|
||||||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
||||||
// The relay GET is dev-only (same flag as the app beacon): consumers
|
// The relay GET is dev-only (same flag as the app beacon): consumers
|
||||||
@@ -161,13 +147,13 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
) else { return }
|
) else { return }
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "intent", value: intentType),
|
URLQueryItem(name: "intent", value: intentType),
|
||||||
URLQueryItem(name: "source", value: entry.locationSource),
|
URLQueryItem(name: "source", value: d.locationSource),
|
||||||
URLQueryItem(name: "n", value: String(entry.stations.count)),
|
URLQueryItem(name: "n", value: String(d.stations.count)),
|
||||||
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
|
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
|
||||||
URLQueryItem(name: "sort", value: entry.sort.rawValue),
|
URLQueryItem(name: "sort", value: d.sort.rawValue),
|
||||||
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
|
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
|
||||||
URLQueryItem(name: "station", value: first?.name ?? ""),
|
URLQueryItem(name: "station", value: first?.name ?? ""),
|
||||||
URLQueryItem(name: "price", value: String(first?.prices[entry.fuel] ?? -1)),
|
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
||||||
]
|
]
|
||||||
guard let url = components.url else { return }
|
guard let url = components.url else { return }
|
||||||
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
||||||
@@ -294,10 +280,12 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
|
|
||||||
return FuelPriceEntry(
|
return FuelPriceEntry(
|
||||||
date: Date(), stations: Array(ordered.prefix(8)),
|
data: FuelPriceWidgetEntryData(
|
||||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
date: Date(), stations: Array(ordered.prefix(8)),
|
||||||
location: location, locationSource: source,
|
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||||
unit: unit
|
location: location, locationSource: source,
|
||||||
|
unit: unit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,123 +356,6 @@ struct FuelPriceWidgetView: View {
|
|||||||
let entry: FuelPriceEntry
|
let entry: FuelPriceEntry
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
FuelPriceWidgetContent(entry: entry.data, family: family)
|
||||||
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(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Tap for directions")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
||||||
.widgetURL(station.widgetDirectionsURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(station.name)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
if let location = entry.location {
|
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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(station.distanceKM(to: location.lat, lng2: 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.widgetDirectionsURL : 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.widgetDirectionsURL ?? 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(station.distanceKM(to: location.lat, lng2: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
1786805147.717644: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-pm/ModuleCache.noindex")
|
1786807226.939194: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-pm/ModuleCache.noindex")
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>LastAccessedDate</key>
|
<key>LastAccessedDate</key>
|
||||||
<date>2026-08-15T14:45:45Z</date>
|
<date>2026-08-15T15:20:26Z</date>
|
||||||
<key>WorkspacePath</key>
|
<key>WorkspacePath</key>
|
||||||
<string>/Users/apt/workspace/fuelboard/FuelBoard.xcodeproj</string>
|
<string>/Users/apt/workspace/fuelboard/FuelBoard.xcodeproj</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
Reference in New Issue
Block a user