Live Activity: cheapest-in-radius Lock Screen/Dynamic Island pill
- Shared FuelBoardLiveActivityAttributes/ContentState (app + widget targets) - FuelBoardLiveActivity Widget: ActivityConfiguration rendered by the widget extension - Lock Screen banner + Dynamic Island (leading/trailing/bottom, compact, minimal), tap anywhere -> maps:// directions to the best station - LiveActivityManager (app side): request/update/end; mirrors the app list's TOP badge (selected fuel + chosen distance radius, price-then-distance ties); local start-date tracking for the 8h cap (this SDK's Activity has no startDate) - auto-restarts at 7h59m; silent no-op when disabled/denied - Wiring: 250m/60s location hook (incl. background significant-change wake-ups), refresh completion, fuel/radius/unit changes, toggle flip - Settings toggle + FuelStore persistence (keychain-backed, app-group) - App-Info.plist: NSSupportsLiveActivities
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// 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.
|
||||
|
||||
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 (iOS 26+ / CarPlay Ultra) the car display.
|
||||
struct FuelBoardLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
||||
// Lock Screen / banner 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock Screen / banner body — the main presentation.
|
||||
private struct FuelBoardLiveActivityView: View {
|
||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||
|
||||
var body: some View {
|
||||
Link(destination: context.state.mapsURL) {
|
||||
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.attributes.fuel.displayName)")
|
||||
.font(.headline)
|
||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// RIGHT — price
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(context.state.priceText)
|
||||
.font(.title2.bold().monospacedDigit())
|
||||
Text("Tap for directions")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||
private struct FuelBoardLiveActivityPriceView: View {
|
||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||
|
||||
var body: some View {
|
||||
Text(context.state.priceText)
|
||||
.font(.headline.bold().monospacedDigit())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.attributes.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, e.g. 161.9 -> "£1.619".
|
||||
var priceText: String {
|
||||
String(format: "£%.3f", pricePence / 100)
|
||||
}
|
||||
|
||||
/// Apple Maps directions URL to the pinned station.
|
||||
var mapsURL: URL {
|
||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ import SwiftUI
|
||||
struct FuelBoardWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
FuelPriceWidget()
|
||||
FuelBoardLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user