- RoadDistanceService builds route endpoints with MKMapItem(location:address:) instead of the MKPlacemark path deprecated in iOS 26. - LiveActivityManager updates through ActivityContent, replacing the ActivityKit update(using:) call deprecated in iOS 16.2. - The location-update hook no longer captures an unused state object; the closure body already runs on self, so behaviour is unchanged.
145 lines
5.6 KiB
Swift
145 lines
5.6 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
|
|
}
|
|
|
|
// ONE cache read for the figure AND its confidence: the activity shows
|
|
// "Approx." exactly when the app's own rows would.
|
|
let distance = FuelStore.stationDistanceDisplay(
|
|
station: best, userLat: location.lat, userLng: location.lng
|
|
)
|
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
|
fuel: fuel,
|
|
stationID: best.id,
|
|
stationName: best.name,
|
|
brand: best.brand,
|
|
pricePence: price,
|
|
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
|
distanceKM: distance.km,
|
|
distanceIsApproximate: distance.isApproximate,
|
|
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(ActivityContent(state: state, staleDate: nil)) }
|
|
}
|
|
} 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? {
|
|
// Radius and tie-break both use the distance the pill SHOWS, so the
|
|
// activity can never name a station the app's own list has filtered out.
|
|
stations
|
|
.filter { $0.prices[fuel] != nil }
|
|
.filter {
|
|
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
|
userLat: lat, userLng: lng)
|
|
}
|
|
.min { lhs, rhs in
|
|
let lPrice = lhs.prices[fuel]!
|
|
let rPrice = rhs.prices[fuel]!
|
|
if lPrice != rPrice { return lPrice < rPrice }
|
|
return FuelStore.displayDistanceKM(station: lhs, userLat: lat, userLng: lng)
|
|
< FuelStore.displayDistanceKM(station: rhs, userLat: lat, userLng: lng)
|
|
}
|
|
}
|
|
}
|