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:
@@ -2,20 +2,9 @@ import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// Fuel colour wheel — mirrors the app's palette in StationsView.swift
|
||||
// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium
|
||||
// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fuel colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from
|
||||
// here 2026-08-16 so the app's widget preview screen uses the same colours).
|
||||
// Shared/FuelStore.swift stays Foundation-only by design.
|
||||
|
||||
// FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes:
|
||||
// .systemSmall: single station (the first of the configured result — the
|
||||
@@ -84,14 +73,8 @@ struct FuelPriceWidget: Widget {
|
||||
}
|
||||
|
||||
struct FuelPriceEntry: TimelineEntry {
|
||||
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
|
||||
let data: FuelPriceWidgetEntryData
|
||||
var date: Date { data.date }
|
||||
}
|
||||
|
||||
struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & WidgetConfigValues>: AppIntentTimelineProvider {
|
||||
@@ -109,9 +92,11 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
.filter { $0.prices[.e10] != nil }
|
||||
.sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
||||
return FuelPriceEntry(
|
||||
date: Date(), stations: Array(sample.prefix(8)),
|
||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||
location: nil, locationSource: "none", unit: unit
|
||||
data: FuelPriceWidgetEntryData(
|
||||
date: Date(), stations: Array(sample.prefix(8)),
|
||||
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
|
||||
/// result — can never affect rendering.
|
||||
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
||||
let d = entry.data
|
||||
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
||||
let first = entry.stations.first
|
||||
let first = d.stations.first
|
||||
let json = """
|
||||
{"intent":"\(intentType)","source":"\(entry.locationSource)",\
|
||||
"n":\(entry.stations.count),"fuel":"\(entry.fuel.rawValue)",\
|
||||
"sort":"\(entry.sort.rawValue)","fav":\(entry.isFavourites),\
|
||||
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
||||
{"intent":"\(intentType)","source":"\(d.locationSource)",\
|
||||
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
|
||||
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
|
||||
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
|
||||
"""
|
||||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
||||
// The relay GET is dev-only (same flag as the app beacon): consumers
|
||||
@@ -161,13 +147,13 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
) else { return }
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "intent", value: intentType),
|
||||
URLQueryItem(name: "source", value: entry.locationSource),
|
||||
URLQueryItem(name: "n", value: String(entry.stations.count)),
|
||||
URLQueryItem(name: "fuel", value: entry.fuel.rawValue),
|
||||
URLQueryItem(name: "sort", value: entry.sort.rawValue),
|
||||
URLQueryItem(name: "fav", value: entry.isFavourites ? "1" : "0"),
|
||||
URLQueryItem(name: "source", value: d.locationSource),
|
||||
URLQueryItem(name: "n", value: String(d.stations.count)),
|
||||
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
|
||||
URLQueryItem(name: "sort", value: d.sort.rawValue),
|
||||
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
|
||||
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 }
|
||||
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
||||
@@ -294,10 +280,12 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
}
|
||||
|
||||
return FuelPriceEntry(
|
||||
date: Date(), stations: Array(ordered.prefix(8)),
|
||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||
location: location, locationSource: source,
|
||||
unit: unit
|
||||
data: FuelPriceWidgetEntryData(
|
||||
date: Date(), stations: Array(ordered.prefix(8)),
|
||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||
location: location, locationSource: source,
|
||||
unit: unit
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,123 +356,6 @@ struct FuelPriceWidgetView: View {
|
||||
let entry: FuelPriceEntry
|
||||
|
||||
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)
|
||||
.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
|
||||
}
|
||||
FuelPriceWidgetContent(entry: entry.data, family: family)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user