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:
Ade Thompson
2026-08-12 20:11:27 +01:00
parent 1dcd47c6cd
commit c362d562d7
8 changed files with 362 additions and 0 deletions
+39
View File
@@ -14,6 +14,7 @@ struct ContentView: View {
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
@@ -188,10 +189,15 @@ struct ContentView: View {
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
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,
fuel: alertsFuel, radiusKM: alertsRadius)
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() }
} else {
@@ -210,6 +216,7 @@ struct ContentView: View {
locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
Task { await refresh(force: true) }
}
}
@@ -223,6 +230,7 @@ struct ContentView: View {
}
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
// No network fetch on foreground pull-to-refresh is the override.
} else {
locationManager.stopForegroundTracking()
@@ -237,11 +245,13 @@ struct ContentView: View {
// NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
updateLiveActivity()
}
}
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
}
.onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK
@@ -250,6 +260,7 @@ struct ContentView: View {
// next render.
FuelStore.saveStationLimit(newValue)
WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
}
.onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue)
@@ -272,6 +283,31 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites,
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
@@ -279,6 +315,7 @@ struct ContentView: View {
private var settingsTab: some View {
SettingsView(
distanceUnit: $distanceUnit,
liveActivityEnabled: $liveActivityEnabled,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult,
@@ -342,6 +379,8 @@ struct ContentView: View {
// Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
// Fresh prices refresh the Live Activity pill too.
updateLiveActivity()
}
}