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:
FuelBoard Contributor
2026-08-15 16:25:10 +01:00
parent 24b6ca9bb2
commit 57121d5213
14 changed files with 316 additions and 160 deletions
+187
View File
@@ -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
}
}
}