GeometryReader as the layout root pinned content top-left, and forcing maxHeight:.infinity over-claimed the whole proposed height, centring the content below true vertical centre (bigger gap above) on the Lock Screen. Demote GeometryReader to a background side-channel that only measures the slot width for the rich-vs-compact branch; the content stays intrinsic-sized so the system vertically centres it itself.
233 lines
10 KiB
Swift
233 lines
10 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 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: context.state.mapsURL) {
|
|
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 + 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")!
|
|
}
|
|
} |