Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A, cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and Live Activity all read one definition (was app-only in StationsView, so the widget couldn't see it). Tint the Live Activity rich card pump icon and the Dynamic Island compactLeading pump by context.state.fuel.tintColor.
211 lines
9.0 KiB
Swift
211 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(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 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(context.state.fuel.tintColor, .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")!
|
|
}
|
|
} |