Files
fuelboard/FuelBoardWidgets/FuelBoardLiveActivityView.swift
T

192 lines
7.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: ActivityConfiguration shares ONE content view across the
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
// closure. So this view provides two layouts and lets ViewThatFits pick by
// available width:
// • richBody — the full three-column design (glyph · fuel+station · price),
// wins wherever there's Lock Screen width (it carries an
// explicit minWidth so it can never be squeezed into the car).
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
// below) that wins in the CarPlay/Apple Watch Smart Stack
// small slot.
import ActivityKit
import SwiftUI
import WidgetKit
/// The Live Activity itself — registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
/// small Live Activity slot — display-only there (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(.green)
} compactTrailing: {
FuelBoardLiveActivityPriceView(context: context)
} minimal: {
Text(context.state.priceText)
.font(.caption2.bold().monospacedDigit())
}
}
.supplementalActivityFamilies([.small])
}
}
/// Lock Screen / banner / CarPlay body — adaptive.
private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View {
Link(destination: context.state.mapsURL) {
ViewThatFits(in: .horizontal) {
// rich first — wins on full-width Lock Screen / banner
richBody
// CarPlay / Watch small slot is far narrower than this,
// so ViewThatFits reliably falls through to compactBody.
.frame(minWidth: 280)
// compact fallback — the car's small supplemental slot
compactBody
}
}
}
/// 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(.green, .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)
}
.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 the small CarPlay / Watch Smart Stack slot:
/// glyph + fuel left, bold price right, truncated station below.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(context.state.fuel.displayName)
.font(.caption2.weight(.semibold))
.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: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.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")!
}
}