Files
fuelboard/FuelBoardWidgets/FuelBoardLiveActivityView.swift
T
FuelBoard Contributor bdfbbc605f live activity: centre content vertically + darken pump circle
A GeometryReader hands its content the full proposed frame and pins it
top-left, so the Lock Screen card sat high instead of vertically centred
when the activity container offered extra height. Wrap the branch in
.frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center).

Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
2026-08-19 22:26:37 +01:00

226 lines
9.7 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
/// Slightly darkened fuel tint for the pump circle so the white pump
/// symbol keeps contrast against the bright Lock Screen / card background
/// (the raw palette colours like yellow #FFD60A are too light to sit under
/// a white glyph).
private var pumpCircleColor: Color {
context.state.fuel.tintColor.mix(with: .black, by: 0.18)
}
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 glyph
Image(systemName: "fuelpump.circle.fill")
.font(.system(size: 32))
.foregroundStyle(pumpCircleColor, .white)
.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")!
}
}