The adaptive layout picks the full three-column view when there's Lock Screen width and a compact price strip in the small CarPlay/Watch slot. Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad always get the full view and only genuinely small slots get the compact strip. Long station names used to clip at inconsistent points across the rich/compact/Dynamic Island views; both bodies now scale + tail- truncate so the caption stays consistent regardless of station name.
204 lines
8.3 KiB
Swift
204 lines
8.3 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>
|
|
|
|
/// Full rich layout needs at least this much width. iPhone/iPad Lock
|
|
/// Screen Live Activities are always wider than this, so they get the
|
|
/// full view; the CarPlay/Apple Watch Smart Stack small slot is far
|
|
/// narrower, so it reliably falls through to `compactBody`.
|
|
private let richMinWidth: CGFloat = 290
|
|
/// Only genuinely small slots get the compact strip — cap it so a
|
|
/// mid-width surface can never squeeze the full view into the car.
|
|
private let compactMaxWidth: CGFloat = 220
|
|
|
|
var body: some View {
|
|
Link(destination: context.state.mapsURL) {
|
|
ViewThatFits(in: .horizontal) {
|
|
// rich first — wins on full-width Lock Screen / banner
|
|
richBody
|
|
.frame(minWidth: richMinWidth)
|
|
// compact fallback — the car's small supplemental slot
|
|
compactBody
|
|
.frame(maxWidth: compactMaxWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
.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 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) · \(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")!
|
|
}
|
|
} |