// 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 /// 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 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 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 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")! } }