fuelpump.circle.fill's first hierarchical style hits the PUMP, not the circle, so the earlier darkened colour landed on the pump and the circle stayed white (user: 'I didn't want the fuel pump darker I wanted the white circle darker'). Rebuild the brand glyph from two explicit shapes: a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so circle vs pump colours are unambiguous and the badge centres cleanly with the two-line text block. Add a DEBUG-only -qaLiveActivity <fuel> launch hook that starts a Live Activity with a long station name so the Lock Screen can be rendered in the Simulator and verified by pixels (pump bright green 43,193,81; badge grey 202,201,202).
236 lines
10 KiB
Swift
236 lines
10 KiB
Swift
// FuelBoardLiveActivityView.swift — renders the FuelBoard Live Activity.
|
|
//
|
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
|
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
|
//
|
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
|
// declared (see note on the config), so iPhone/iPad always render the full
|
|
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
|
|
// as a safety fallback should any surface ever hand this view a narrow width.
|
|
//
|
|
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent —
|
|
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
|
|
// CarPlay's small form comes from the Dynamic Island compact closures.
|
|
|
|
import ActivityKit
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
/// The Live Activity itself — registered in the widget bundle alongside the
|
|
/// regular price widget. No CarPlay entitlement involved: renders on the
|
|
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
|
|
/// (incl. the island's compact form used in the car, display-only — FuelBoard
|
|
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
|
|
struct FuelBoardLiveActivity: Widget {
|
|
var body: some WidgetConfiguration {
|
|
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
|
// Lock Screen / banner / CarPlay small slot presentation
|
|
FuelBoardLiveActivityView(context: context)
|
|
} dynamicIsland: { context in
|
|
DynamicIsland {
|
|
DynamicIslandExpandedRegion(.leading) {
|
|
FuelBoardLiveActivityCompactView(context: context)
|
|
}
|
|
DynamicIslandExpandedRegion(.trailing) {
|
|
FuelBoardLiveActivityPriceView(context: context)
|
|
}
|
|
DynamicIslandExpandedRegion(.bottom) {
|
|
FuelBoardLiveActivityStationView(context: context)
|
|
}
|
|
} compactLeading: {
|
|
Image(systemName: "fuelpump.fill")
|
|
.foregroundStyle(context.state.fuel.tintColor)
|
|
} compactTrailing: {
|
|
FuelBoardLiveActivityPriceView(context: context)
|
|
} minimal: {
|
|
Text(context.state.priceText)
|
|
.font(.caption2.bold().monospacedDigit())
|
|
}
|
|
}
|
|
.supplementalActivityFamilies([.small])
|
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
|
// instead of falling back to the Dynamic Island compact closure —
|
|
// which could NOT show the station distance the user wants on CarPlay.
|
|
// The full-width iPhone/iPad card is protected by the `richMinWidth`
|
|
// gate on `richBody` + its flexible, truncating middle column, so
|
|
// iPhone/iPad still get the full card; only truly small space picks
|
|
// the compact strip below.
|
|
}
|
|
}
|
|
|
|
/// Lock Screen / banner / CarPlay body — adaptive.
|
|
private struct FuelBoardLiveActivityView: View {
|
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
|
|
|
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
|
|
/// small / Watch smart stack); at/above it we show the full card. The
|
|
/// decision is made from the real space the system hands the body via
|
|
/// GeometryReader, NOT from ViewThatFits ideal-width measurement — the
|
|
/// latter is broken for truncating text (a long station name inflates the
|
|
/// "ideal" width past the iPhone Lock Screen and collapses the full card).
|
|
private let compactWidthThreshold: CGFloat = 280
|
|
|
|
/// The badge circle behind the pump. The user calls this "the white
|
|
/// circle" and wants it slightly darkened for contrast against the bright
|
|
/// fuel pump. Built as an explicit grey disc (the light-grey "white circle
|
|
/// darker") — NOT part of the SF Symbol glyph, so it can never swallow the
|
|
/// darkened colour the way `fuelpump.circle.fill` did.
|
|
private var badgeCircleColor: Color {
|
|
Color(white: 0.86)
|
|
}
|
|
|
|
var body: some View {
|
|
Link(destination: context.state.mapsURL) {
|
|
GeometryReader { geo in
|
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
|
// how long the station name is. Truly small slots (CarPlay /
|
|
// Watch) offer much less → compact strip.
|
|
Group {
|
|
if geo.size.width >= compactWidthThreshold {
|
|
richBody
|
|
} else {
|
|
compactBody
|
|
}
|
|
}
|
|
// A GeometryReader hands its content the FULL proposed frame
|
|
// (content is otherwise pinned top-left), so centre the card
|
|
// within that space — keeps the Lock Screen content vertically
|
|
// aligned instead of sitting high.
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
|
private var richBody: some View {
|
|
HStack(spacing: 12) {
|
|
// LEFT — station brand badge: an explicit darkened circle with a
|
|
// bright fuel-tinted pump. Built from two shapes so the circle
|
|
// colour and pump colour are unambiguous (the myth that
|
|
// fuelpump.circle.fill's first style is the circle is what put the
|
|
// darkened colour on the pump).
|
|
ZStack {
|
|
Circle()
|
|
.fill(badgeCircleColor)
|
|
.frame(width: 40, height: 40)
|
|
Image(systemName: "fuelpump.fill")
|
|
.font(.system(size: 19, weight: .semibold))
|
|
.foregroundStyle(context.state.fuel.tintColor)
|
|
}
|
|
.frame(width: 40, height: 40)
|
|
|
|
// MIDDLE — fuel + station
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Cheapest \(context.state.fuel.displayName)")
|
|
.font(.headline)
|
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.truncationMode(.tail)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// RIGHT — price
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
|
style: context.state.priceDisplayStyle,
|
|
size: 22, weight: .bold)
|
|
Text("Tap for directions")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
|
/// fuel type + bold price on one line, station · distance below.
|
|
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
|
/// display-only, and the user's asks here are just fuel + price + distance.
|
|
private var compactBody: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 5) {
|
|
Text(context.state.fuel.displayName)
|
|
.font(.caption.bold())
|
|
.lineLimit(1)
|
|
Spacer(minLength: 4)
|
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
|
style: context.state.priceDisplayStyle,
|
|
size: 15, weight: .bold)
|
|
.lineLimit(1)
|
|
}
|
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
.truncationMode(.tail)
|
|
}
|
|
.padding(8)
|
|
}
|
|
}
|
|
|
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
|
private struct FuelBoardLiveActivityPriceView: View {
|
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
|
|
|
var body: some View {
|
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
|
style: context.state.priceDisplayStyle,
|
|
size: 17, weight: .bold)
|
|
}
|
|
}
|
|
|
|
/// Dynamic Island expanded — fuel + station name.
|
|
private struct FuelBoardLiveActivityCompactView: View {
|
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Cheapest \(context.state.fuel.displayName)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Text(context.state.stationName)
|
|
.font(.caption.bold())
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dynamic Island expanded bottom — station + distance.
|
|
private struct FuelBoardLiveActivityStationView: View {
|
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
|
|
|
var body: some View {
|
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
// MARK: - ContentState display helpers
|
|
|
|
extension FuelBoardLiveActivityAttributes.ContentState {
|
|
/// Display distance using the user's chosen unit (miles default).
|
|
var distanceText: String {
|
|
FuelStore.loadDistanceUnit().format(distanceKM)
|
|
}
|
|
|
|
/// Price string from pence, in the user's chosen display style
|
|
/// (station sign "129.9" or pounds "£1.29⁹/L"). Uses the style captured
|
|
/// in the state at push time.
|
|
var priceText: String {
|
|
FuelStore.priceText(pricePence, style: priceDisplayStyle)
|
|
}
|
|
|
|
/// Apple Maps directions URL to the pinned station.
|
|
var mapsURL: URL {
|
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
|
}
|
|
} |