Files
fuelboard/FuelBoard/LiveActivityManager.swift
T
FuelBoard Contributor 64ef260959 widget + live activity: prefer Apple-Maps road distance, computed by the app
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:

- New RoadDistanceService (app target): for the nearest 12 stations each
  pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
  and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
  even on free SideStore accounts with no app-group container; only valid
  within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
  wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
  distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
2026-08-20 11:33:47 +01:00

133 lines
4.9 KiB
Swift

// 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.
/// `priceDisplayStyle` overrides the saved style (used when the setting
/// just changed and the save may not have landed yet); nil = read storage.
static func update(
stations: [FuelStation],
fuel: FuelType,
radiusKM: Double,
location: Coordinate?,
enabled: Bool,
priceDisplayStyle: PriceDisplayStyle? = nil
) {
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(
fuel: fuel,
stationID: best.id,
stationName: best.name,
brand: best.brand,
pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
lat: best.lat,
lng: best.lng,
updatedAt: Date()
)
let attributes = FuelBoardLiveActivityAttributes()
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)
}
}
}