// 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? /// 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) } } }