From c362d562d74f13f7cf2ae37e138061a74359ddb2 Mon Sep 17 00:00:00 2001 From: Ade Thompson Date: Wed, 12 Aug 2026 20:11:27 +0100 Subject: [PATCH] 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 --- Config/App-Info.plist | 2 + FuelBoard/ContentView.swift | 39 +++++ FuelBoard/LiveActivityManager.swift | 127 ++++++++++++++++ FuelBoard/SettingsView.swift | 15 ++ .../FuelBoardLiveActivityView.swift | 135 ++++++++++++++++++ FuelBoardWidgets/FuelBoardWidgetsBundle.swift | 1 + Shared/FuelBoardLiveActivity.swift | 32 +++++ Shared/FuelStore.swift | 11 ++ 8 files changed, 362 insertions(+) create mode 100644 FuelBoard/LiveActivityManager.swift create mode 100644 FuelBoardWidgets/FuelBoardLiveActivityView.swift create mode 100644 Shared/FuelBoardLiveActivity.swift diff --git a/Config/App-Info.plist b/Config/App-Info.plist index b0afc51..db0e528 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -39,6 +39,8 @@ FuelBoard uses Always location to alert you when you approach the cheapest station nearby. NSLocalNetworkUsageDescription FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices. + NSSupportsLiveActivities + UIBackgroundModes location diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 1b0f587..86cd914 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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() } } diff --git a/FuelBoard/LiveActivityManager.swift b/FuelBoard/LiveActivityManager.swift new file mode 100644 index 0000000..1629d3c --- /dev/null +++ b/FuelBoard/LiveActivityManager.swift @@ -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? + /// 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) + } + } +} diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index d6e2eb4..824c124 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -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() diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift new file mode 100644 index 0000000..802b264 --- /dev/null +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -0,0 +1,135 @@ +// FuelBoardLiveActivityView.swift — renders the FuelBoard Live Activity. +// +// Lives in the widget extension (the standard host for ActivityConfiguration). +// Shows the cheapest station for the pinned fuel within the app's chosen +// radius. Tapping anywhere opens Apple Maps directions to that station. + +import ActivityKit +import SwiftUI +import WidgetKit + +/// The Live Activity itself — registered in the widget bundle alongside the +/// regular price widget. No CarPlay entitlement involved: this renders on the +/// Lock Screen, Dynamic Island, and (iOS 26+ / CarPlay Ultra) the car display. +struct FuelBoardLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in + // Lock Screen / banner presentation + FuelBoardLiveActivityView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + FuelBoardLiveActivityCompactView(context: context) + } + DynamicIslandExpandedRegion(.trailing) { + FuelBoardLiveActivityPriceView(context: context) + } + DynamicIslandExpandedRegion(.bottom) { + FuelBoardLiveActivityStationView(context: context) + } + } compactLeading: { + Image(systemName: "fuelpump.fill") + .foregroundStyle(.green) + } compactTrailing: { + FuelBoardLiveActivityPriceView(context: context) + } minimal: { + Text(context.state.priceText) + .font(.caption2.bold().monospacedDigit()) + } + } + } +} + +/// Lock Screen / banner body — the main presentation. +private struct FuelBoardLiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + Link(destination: context.state.mapsURL) { + HStack(spacing: 12) { + // LEFT — station brand glyph + Image(systemName: "fuelpump.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(.green, .white) + .frame(width: 40, height: 40) + + // MIDDLE — fuel + station + VStack(alignment: .leading, spacing: 2) { + Text("Cheapest \(context.attributes.fuel.displayName)") + .font(.headline) + Text("\(context.state.stationName) · \(context.state.distanceText)") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // RIGHT — price + VStack(alignment: .trailing, spacing: 2) { + Text(context.state.priceText) + .font(.title2.bold().monospacedDigit()) + Text("Tap for directions") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding() + } + } +} + +/// Dynamic Island expanded regions + compact trailing — price only. +private struct FuelBoardLiveActivityPriceView: View { + let context: ActivityViewContext + + var body: some View { + Text(context.state.priceText) + .font(.headline.bold().monospacedDigit()) + } +} + +/// Dynamic Island expanded — fuel + station name. +private struct FuelBoardLiveActivityCompactView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Cheapest \(context.attributes.fuel.displayName)") + .font(.caption2) + .foregroundStyle(.secondary) + Text(context.state.stationName) + .font(.caption.bold()) + .lineLimit(1) + } + } +} + +/// Dynamic Island expanded bottom — station + distance. +private struct FuelBoardLiveActivityStationView: View { + let context: ActivityViewContext + + var body: some View { + Text("\(context.state.stationName) · \(context.state.distanceText)") + .font(.caption) + .lineLimit(1) + } +} + +// MARK: - ContentState display helpers + +extension FuelBoardLiveActivityAttributes.ContentState { + /// Display distance using the user's chosen unit (miles default). + var distanceText: String { + FuelStore.loadDistanceUnit().format(distanceKM) + } + + /// £ price string from pence, e.g. 161.9 -> "£1.619". + var priceText: String { + String(format: "£%.3f", pricePence / 100) + } + + /// Apple Maps directions URL to the pinned station. + var mapsURL: URL { + URL(string: "maps://?daddr=\(lat),\(lng)&t=d")! + } +} diff --git a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift index 87e49dc..aa64666 100644 --- a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift +++ b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift @@ -5,5 +5,6 @@ import SwiftUI struct FuelBoardWidgetsBundle: WidgetBundle { var body: some Widget { FuelPriceWidget() + FuelBoardLiveActivity() } } diff --git a/Shared/FuelBoardLiveActivity.swift b/Shared/FuelBoardLiveActivity.swift new file mode 100644 index 0000000..78cc066 --- /dev/null +++ b/Shared/FuelBoardLiveActivity.swift @@ -0,0 +1,32 @@ +// FuelBoardLiveActivity.swift — Live Activity shared model. +// +// Compiled into BOTH the app target (to start/update/end the activity) and +// the widget extension (to render it). ActivityKit is iOS-only, so this file +// is deliberately NOT symlinked into FuelBoardTests/Sources — the SPM test +// target runs on macOS where ActivityKit does not exist. +// +// The activity mirrors the app's TOP badge: the cheapest station selling the +// app's selected fuel within the app's chosen distance radius. Tapping the +// activity opens Apple Maps driving directions to that station (maps://). + +import ActivityKit +import Foundation + +/// Attributes are fixed at request time. Here they pin the FUEL being +/// tracked; everything that can change while driving (station, price, +/// distance) lives in ContentState so updates don't need a new activity. +struct FuelBoardLiveActivityAttributes: ActivityAttributes { + var fuel: FuelType + + public struct ContentState: Codable, Hashable { + var stationID: String + var stationName: String + var brand: String + /// Price in pence per litre (same unit as `FuelStation.prices`). + var pricePence: Double + var distanceKM: Double + var lat: Double + var lng: Double + var updatedAt: Date + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 4344a9a..f08f928 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -262,6 +262,7 @@ struct FuelStore { static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag + static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" @@ -490,6 +491,16 @@ struct FuelStore { saveString(String(radius), service: alertsRadiusKey) } + // MARK: Live Activity + + static func loadLiveActivityEnabled() -> Bool { + loadString(service: liveActivityKey) == "1" + } + + static func saveLiveActivityEnabled(_ enabled: Bool) { + saveString(enabled ? "1" : "0", service: liveActivityKey) + } + // MARK: Refresh policy — data is cached; the app only auto-refreshes // twice a day (pull-to-refresh is the manual override).