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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import WidgetKit
|
||||
/// with no criteria at all.
|
||||
struct SettingsView: View {
|
||||
@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
|
||||
/// tab) so the test notification matches what real alerts will say.
|
||||
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.")
|
||||
}
|
||||
|
||||
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 {
|
||||
Button {
|
||||
onShowOnboarding()
|
||||
|
||||
Reference in New Issue
Block a user