Files
fuelboard/FuelBoardWidgets/FuelBoardLiveActivityView.swift
T
FuelBoard Contributor 20fe251b8e feat: honour Tap station action in widgets
Small widgetURL and medium list Links route through
StationTapAction via FuelStation.tapDestinationURL: Open map
keeps the Maps URL, More info uses a fuelboard://station
deep link (stationID plus coordinate/name fallback).

Deep link carries fallback coords; ContentView presents the
detail sheet when the station is cached, else the map sheet.
Live Activity detailURL upgraded to the same rich link.
2026-09-16 16:15:07 +01:00

273 lines
12 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 honours Settings → Tap station: Open map opens Apple Maps
// directions to that station, More info opens the detail sheet in the app.
//
// 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 width the system hands the body, read
/// via a background GeometryReader — NOT ViewThatFits ideal-width
/// measurement (that's broken for truncating text: a long station name
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
/// the full card).
private let compactWidthThreshold: CGFloat = 280
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
/// background GeometryReader so it does NOT act as the layout container:
/// a GeometryReader root pins content top-left, and forcing a
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
/// centring the content below true vertical centre (bigger gap above) on
/// the Lock Screen. Measuring behind the scenes keeps the content
/// intrinsic-sized so the system vertically centres it itself.
@State private var slotWidth: CGFloat = 400
var body: some View {
Link(destination: tapDestination) {
Group {
// 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.
if slotWidth >= compactWidthThreshold {
richBody
} else {
compactBody
}
}
// Fill the card width so the background measure reads the real
// slot, not the intrinsic content width.
.frame(maxWidth: .infinity)
// Side-channel width measurement — never the layout container.
.background(
GeometryReader { geo in
Color.clear
.onAppear { slotWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in slotWidth = w }
}
)
}
}
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
private var richBody: some View {
HStack(spacing: 12) {
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
// background circle behind it (user request).
Image(systemName: "fuelpump.fill")
.font(.system(size: 28, 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 + distance + bold price on one line, station name below.
/// Deliberately no app name and no full address — the watch-sized slot is
/// too tight, so the second line is reserved for the station name only.
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)
Text(context.state.distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 15, weight: .bold)
.lineLimit(1)
}
Text(context.state.stationName)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.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 {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.caption)
.lineLimit(1)
}
}
}
/// Tap routing shared by the Lock Screen card and the island: honours
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
enum FuelBoardLiveActivityTap {
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
}
}
private extension FuelBoardLiveActivityView {
/// Lock Screen tap honours Settings → Tap station.
var tapDestination: URL {
FuelBoardLiveActivityTap.tapDestination(for: context.state)
}
}
// 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")!
}
/// In-app deep link to the station's detail sheet, used when
/// Settings → Tap station = More info. Carries coordinate/name fallback
/// so the app can still show the map sheet when the station has left
/// the cache.
var detailURL: URL {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: stationID),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: stationName),
]
return components.url!
}
}