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:
FuelBoard Contributor
2026-08-12 20:11:42 +01:00
parent 1dcd47c6cd
commit 04b233bf9a
8 changed files with 362 additions and 0 deletions
+2
View File
@@ -39,6 +39,8 @@
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string> <string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string> <string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
+39
View File
@@ -14,6 +14,7 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() @State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var location: Coordinate? = { @State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) } if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil return nil
@@ -188,10 +189,15 @@ struct ContentView: View {
locationManager.onLocationUpdate = { [weak monitor] in locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites, monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
// The Live Activity follows the same wake-ups this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
} }
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy). // Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() } Task { await refresh() }
} else { } else {
@@ -210,6 +216,7 @@ struct ContentView: View {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
Task { await refresh(force: true) } Task { await refresh(force: true) }
} }
} }
@@ -223,6 +230,7 @@ struct ContentView: View {
} }
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
// No network fetch on foreground pull-to-refresh is the override. // No network fetch on foreground pull-to-refresh is the override.
} else { } else {
locationManager.stopForegroundTracking() locationManager.stopForegroundTracking()
@@ -237,11 +245,13 @@ struct ContentView: View {
// NOT re-fetched on every movement (cached, twice-a-day policy). // NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
} }
} }
.onChange(of: selectedFuel) { _, _ in .onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices. // No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
} }
.onChange(of: stationLimit) { _, newValue in .onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK // Distance filter is LOCAL math now the cache holds the full-UK
@@ -250,6 +260,7 @@ struct ContentView: View {
// next render. // next render.
FuelStore.saveStationLimit(newValue) FuelStore.saveStationLimit(newValue)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
} }
.onChange(of: alertsEnabled) { _, newValue in .onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue) FuelStore.saveAlertsEnabled(newValue)
@@ -272,6 +283,31 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: newValue, radiusKM: alertsRadius) fuel: newValue, radiusKM: alertsRadius)
} }
.onChange(of: liveActivityEnabled) { _, newValue in
// Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue)
updateLiveActivity()
}
.onChange(of: distanceUnit) { _, _ in
// Distance unit changes the radius mirror the new radius in the
// Live Activity immediately.
updateLiveActivity()
}
}
/// Pushes the current best-in-radius station into the Live Activity.
/// No-op (or ends the activity) when the toggle is off or there's no
/// location/data yet. Mirrors the app list's TOP badge: selected fuel +
/// chosen distance radius, cheapest then nearest on ties.
private func updateLiveActivity() {
LiveActivityManager.update(
stations: stations,
fuel: selectedFuel,
radiusKM: distanceUnit.toKM(Double(stationLimit)),
location: location,
enabled: liveActivityEnabled
)
} }
/// The Settings tab, extracted from `body` so the TabView expression stays /// The Settings tab, extracted from `body` so the TabView expression stays
@@ -279,6 +315,7 @@ struct ContentView: View {
private var settingsTab: some View { private var settingsTab: some View {
SettingsView( SettingsView(
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
liveActivityEnabled: $liveActivityEnabled,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
@@ -342,6 +379,8 @@ struct ContentView: View {
// Keep monitor geofences in sync with the freshest data. // Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius) fuel: alertsFuel, radiusKM: alertsRadius)
// Fresh prices refresh the Live Activity pill too.
updateLiveActivity()
} }
} }
+127
View File
@@ -0,0 +1,127 @@
// LiveActivityManager.swift app-side driver for the FuelBoard Live Activity.
//
// The app owns the activity lifecycle (request / update / end) because the
// activity's own sandbox cannot fetch prices or read location. It mirrors the
// app's TOP badge exactly: cheapest station selling the SELECTED fuel within
// the CHOSEN distance radius. Call sites:
// - every location fix (foreground AND background significant-change wake-ups)
// - after every station refresh
// - when the Settings toggle flips
//
// Live Activities auto-end after 8 h this manager re-requests a fresh one
// just before the cap so a long drive keeps its glanceable pill.
import ActivityKit
import Foundation
@MainActor
enum LiveActivityManager {
/// The running activity, if any.
private static var current: Activity<FuelBoardLiveActivityAttributes>?
/// When `current` was requested. This SDK's `Activity` no longer exposes
/// `startDate`, so the manager tracks it locally to honour the 8 h cap.
private static var currentStartDate: Date?
/// The system ends Live Activities after 8 hours. Restart at 7h59m so the
/// pill never silently disappears mid-drive.
private static let restartThreshold: TimeInterval = 7 * 3600 + 59 * 60
/// Recomputes the cheapest-in-radius station and updates the activity.
/// Passing `enabled: false` (or no usable data) ends any running activity.
static func update(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
location: Coordinate?,
enabled: Bool
) {
guard enabled, let location else {
end()
return
}
guard let best = cheapestStation(
stations: stations, fuel: fuel, radiusKM: radiusKM,
lat: location.lat, lng: location.lng
), let price = best.prices[fuel] else {
end()
return
}
let state = FuelBoardLiveActivityAttributes.ContentState(
stationID: best.id,
stationName: best.name,
brand: best.brand,
pricePence: price,
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
lat: best.lat,
lng: best.lng,
updatedAt: Date()
)
let attributes = FuelBoardLiveActivityAttributes(fuel: fuel)
if let current {
// Near the 8 h cap? End and re-request so the pill keeps living.
let age = currentStartDate.map { Date().timeIntervalSince($0) } ?? 0
if age >= restartThreshold {
Task { await current.end(nil, dismissalPolicy: .immediate) }
self.current = nil
currentStartDate = nil
start(attributes: attributes, state: state)
} else {
Task { await current.update(using: state) }
}
} else {
start(attributes: attributes, state: state)
}
}
/// Ends any running activity (toggle off / app reset).
static func end() {
guard let current else { return }
Task { await current.end(nil, dismissalPolicy: .immediate) }
self.current = nil
currentStartDate = nil
}
// MARK: - Internals
private static func start(
attributes: FuelBoardLiveActivityAttributes,
state: FuelBoardLiveActivityAttributes.ContentState
) {
do {
current = try Activity.request(
attributes: attributes,
content: .init(state: state, staleDate: nil),
pushType: nil
)
currentStartDate = Date()
} catch {
// User disabled Live Activities in Settings, or system budget
// silent: the feature just doesn't appear.
current = nil
currentStartDate = nil
}
}
/// Cheapest station selling `fuel` within `radiusKM` of the location
/// identical semantics to the app list's TOP badge (price first, then
/// distance as the tie-breaker).
static func cheapestStation(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
lat: Double,
lng: Double
) -> FuelStation? {
stations
.filter { $0.prices[fuel] != nil }
.filter { $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
.min { lhs, rhs in
let lPrice = lhs.prices[fuel]!
let rPrice = rhs.prices[fuel]!
if lPrice != rPrice { return lPrice < rPrice }
return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
}
}
}
+15
View File
@@ -12,6 +12,10 @@ import WidgetKit
/// with no criteria at all. /// with no criteria at all.
struct SettingsView: View { struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
/// / Dynamic Island. Mirrors the app list's TOP badge (selected fuel +
/// distance radius) and re-updates as the user drives.
@Binding var liveActivityEnabled: Bool
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 var alertsFuel: FuelType = .e10
@@ -66,6 +70,17 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit.") Text("Distances and search radii across the app, widget and alerts are shown in this unit.")
} }
Section {
Toggle("Live Activity", isOn: $liveActivityEnabled)
} header: {
Text("Live Activity")
} footer: {
Text("Shows the cheapest station for your selected fuel within your chosen distance on the Lock Screen and Dynamic Island. Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
}
.onChange(of: liveActivityEnabled) { _, newValue in
FuelStore.saveLiveActivityEnabled(newValue)
}
Section { Section {
Button { Button {
onShowOnboarding() onShowOnboarding()
@@ -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 { struct FuelBoardWidgetsBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
FuelPriceWidget() FuelPriceWidget()
FuelBoardLiveActivity()
} }
} }
+32
View File
@@ -0,0 +1,32 @@
// FuelBoardLiveActivity.swift Live Activity shared model.
//
// Compiled into BOTH the app target (to start/update/end the activity) and
// the widget extension (to render it). ActivityKit is iOS-only, so this file
// is deliberately NOT symlinked into FuelBoardTests/Sources the SPM test
// target runs on macOS where ActivityKit does not exist.
//
// The activity mirrors the app's TOP badge: the cheapest station selling the
// app's selected fuel within the app's chosen distance radius. Tapping the
// activity opens Apple Maps driving directions to that station (maps://).
import ActivityKit
import Foundation
/// Attributes are fixed at request time. Here they pin the FUEL being
/// tracked; everything that can change while driving (station, price,
/// distance) lives in ContentState so updates don't need a new activity.
struct FuelBoardLiveActivityAttributes: ActivityAttributes {
var fuel: FuelType
public struct ContentState: Codable, Hashable {
var stationID: String
var stationName: String
var brand: String
/// Price in pence per litre (same unit as `FuelStation.prices`).
var pricePence: Double
var distanceKM: Double
var lat: Double
var lng: Double
var updatedAt: Date
}
}
+11
View File
@@ -262,6 +262,7 @@ struct FuelStore {
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let debugModeKey = "fuelboard.debugMode" // Bool hidden dev flag static let debugModeKey = "fuelboard.debugMode" // Bool hidden dev flag
static let liveActivityKey = "fuelboard.liveActivity" // Bool Live Activity toggle
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv" static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv"
@@ -490,6 +491,16 @@ struct FuelStore {
saveString(String(radius), service: alertsRadiusKey) saveString(String(radius), service: alertsRadiusKey)
} }
// MARK: Live Activity
static func loadLiveActivityEnabled() -> Bool {
loadString(service: liveActivityKey) == "1"
}
static func saveLiveActivityEnabled(_ enabled: Bool) {
saveString(enabled ? "1" : "0", service: liveActivityKey)
}
// MARK: Refresh policy data is cached; the app only auto-refreshes // MARK: Refresh policy data is cached; the app only auto-refreshes
// twice a day (pull-to-refresh is the manual override). // twice a day (pull-to-refresh is the manual override).