ViewThatFits measures each child's IDEAL (untruncated) width to decide fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations) inflated richBody's ideal past the iPhone Lock Screen width and fell back to the compact strip. Replace the ViewThatFits branch with a GeometryReader that reads the ACTUAL proposed width and shows the full three-column card at/above 280pt, compact below. Content-independent, so a long name can no longer collapse the iPhone card.
211 lines
8.9 KiB
Swift
211 lines
8.9 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(.green)
|
|
} 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 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
|
|
|
|
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.
|
|
if geo.size.width >= compactWidthThreshold {
|
|
richBody
|
|
} else {
|
|
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)
|
|
.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")!
|
|
}
|
|
} |