Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact fallback so CarPlay's small slot renders our shared compact strip (fuel type, bold price, station · distance) instead of falling back to the Dynamic Island compact closure, which showed the app name and omitted the distance the user wants. Full iPhone/iPad card is preserved via the richMinWidth gate on richBody plus its flexible, truncating middle column (long names can no longer collapse it).
212 lines
9.0 KiB
Swift
212 lines
9.0 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>
|
|
|
|
/// Full card needs at least this much width; iPhone/iPad Lock Screen is
|
|
/// always wider, so they always get it. Only genuinely small slots
|
|
/// (CarPlay small / Watch smart stack) fall through to `compactBody`.
|
|
private let richMinWidth: CGFloat = 280
|
|
/// Cap the compact strip so a mid-width surface can't sneak it in.
|
|
private let compactMaxWidth: CGFloat = 230
|
|
|
|
var body: some View {
|
|
Link(destination: context.state.mapsURL) {
|
|
ViewThatFits(in: .horizontal) {
|
|
// rich FIRST — wins on the full-width iPhone/iPad Lock Screen.
|
|
// Its middle column is flexible + line-limited, so a LONG
|
|
// station name truncates in place and never inflates the ideal
|
|
// width (which used to trick ViewThatFits into falling back to
|
|
// compact on iPhone).
|
|
richBody
|
|
.frame(minWidth: richMinWidth)
|
|
// compact fallback — the small CarPlay / Watch smart-stack
|
|
// strip: fuel · price · station distance (no app name).
|
|
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 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")!
|
|
}
|
|
} |