Live Activity: own fuel + distance config, independent of Stations tab

- Settings → Live Activity now has Fuel and Distance pickers (5/10/15,
  unit-aware) stored under fuelboard.liveActivityFuel / liveActivityRadiusMiles
- LiveActivityManager uses these instead of the Stations-tab selectedFuel /
  stationLimit; Stations changes no longer re-target the pill
- Fuel moved from Activity attributes into ContentState so changing fuel in
  Settings updates the RUNNING activity in place (no end+restart needed)
- Stations tab stays a pure data-interrogation + favourites surface
This commit is contained in:
FuelBoard Contributor
2026-08-12 20:58:05 +01:00
parent 04b233bf9a
commit fcb83f1eed
6 changed files with 82 additions and 20 deletions
+18 -6
View File
@@ -15,6 +15,8 @@ struct ContentView: View {
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() @State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled() @State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
@State private var liveActivityRadiusMiles: Int = FuelStore.loadLiveActivityRadiusMiles()
@State private var location: Coordinate? = { @State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) } if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil return nil
@@ -251,7 +253,6 @@ struct ContentView: View {
.onChange(of: selectedFuel) { _, _ in .onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices. // No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
} }
.onChange(of: stationLimit) { _, newValue in .onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK // Distance filter is LOCAL math now the cache holds the full-UK
@@ -260,7 +261,6 @@ struct ContentView: View {
// next render. // next render.
FuelStore.saveStationLimit(newValue) FuelStore.saveStationLimit(newValue)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
updateLiveActivity()
} }
.onChange(of: alertsEnabled) { _, newValue in .onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue) FuelStore.saveAlertsEnabled(newValue)
@@ -289,6 +289,16 @@ struct ContentView: View {
FuelStore.saveLiveActivityEnabled(newValue) FuelStore.saveLiveActivityEnabled(newValue)
updateLiveActivity() updateLiveActivity()
} }
.onChange(of: liveActivityFuel) { _, newValue in
// The pill tracks its OWN fuel independent of the Stations tab.
FuelStore.saveLiveActivityFuel(newValue)
updateLiveActivity()
}
.onChange(of: liveActivityRadiusMiles) { _, newValue in
// Same for its own radius (miles, converted at use).
FuelStore.saveLiveActivityRadiusMiles(newValue)
updateLiveActivity()
}
.onChange(of: distanceUnit) { _, _ in .onChange(of: distanceUnit) { _, _ in
// Distance unit changes the radius mirror the new radius in the // Distance unit changes the radius mirror the new radius in the
// Live Activity immediately. // Live Activity immediately.
@@ -298,13 +308,13 @@ struct ContentView: View {
/// Pushes the current best-in-radius station into the Live Activity. /// 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 /// 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 + /// location/data yet. Uses the Live Activity's OWN fuel + radius (set in
/// chosen distance radius, cheapest then nearest on ties. /// Settings) independent of the Stations-tab fuel/distance.
private func updateLiveActivity() { private func updateLiveActivity() {
LiveActivityManager.update( LiveActivityManager.update(
stations: stations, stations: stations,
fuel: selectedFuel, fuel: liveActivityFuel,
radiusKM: distanceUnit.toKM(Double(stationLimit)), radiusKM: distanceUnit.toKM(Double(liveActivityRadiusMiles)),
location: location, location: location,
enabled: liveActivityEnabled enabled: liveActivityEnabled
) )
@@ -316,6 +326,8 @@ struct ContentView: View {
SettingsView( SettingsView(
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
liveActivityEnabled: $liveActivityEnabled, liveActivityEnabled: $liveActivityEnabled,
liveActivityFuel: $liveActivityFuel,
liveActivityRadiusMiles: $liveActivityRadiusMiles,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
+2 -1
View File
@@ -48,6 +48,7 @@ enum LiveActivityManager {
} }
let state = FuelBoardLiveActivityAttributes.ContentState( let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: best.id, stationID: best.id,
stationName: best.name, stationName: best.name,
brand: best.brand, brand: best.brand,
@@ -57,7 +58,7 @@ enum LiveActivityManager {
lng: best.lng, lng: best.lng,
updatedAt: Date() updatedAt: Date()
) )
let attributes = FuelBoardLiveActivityAttributes(fuel: fuel) let attributes = FuelBoardLiveActivityAttributes()
if let current { if let current {
// Near the 8 h cap? End and re-request so the pill keeps living. // Near the 8 h cap? End and re-request so the pill keeps living.
+23 -3
View File
@@ -13,9 +13,13 @@ import WidgetKit
struct SettingsView: View { struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen /// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
/// / Dynamic Island. Mirrors the app list's TOP badge (selected fuel + /// / Dynamic Island. Tracks its OWN fuel + radius (below) independent of
/// distance radius) and re-updates as the user drives. /// the Stations tab.
@Binding var liveActivityEnabled: Bool @Binding var liveActivityEnabled: Bool
/// Fuel the Live Activity tracks (independent of the Stations tab).
@Binding var liveActivityFuel: FuelType
/// Search radius (miles, 5/10/15) the Live Activity uses.
@Binding var liveActivityRadiusMiles: Int
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 var alertsFuel: FuelType = .e10
@@ -72,10 +76,26 @@ struct SettingsView: View {
Section { Section {
Toggle("Live Activity", isOn: $liveActivityEnabled) Toggle("Live Activity", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
.onChange(of: liveActivityFuel) { _, newValue in
FuelStore.saveLiveActivityFuel(newValue)
}
Picker("Distance", selection: $liveActivityRadiusMiles) {
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text("\(miles) \(distanceUnit.label)").tag(miles)
}
}
.onChange(of: liveActivityRadiusMiles) { _, newValue in
FuelStore.saveLiveActivityRadiusMiles(newValue)
}
} header: { } header: {
Text("Live Activity") Text("Live Activity")
} footer: { } 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.") Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab. Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
} }
.onChange(of: liveActivityEnabled) { _, newValue in .onChange(of: liveActivityEnabled) { _, newValue in
FuelStore.saveLiveActivityEnabled(newValue) FuelStore.saveLiveActivityEnabled(newValue)
@@ -55,7 +55,7 @@ private struct FuelBoardLiveActivityView: View {
// MIDDLE fuel + station // MIDDLE fuel + station
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.attributes.fuel.displayName)") Text("Cheapest \(context.state.fuel.displayName)")
.font(.headline) .font(.headline)
Text("\(context.state.stationName) · \(context.state.distanceText)") Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.subheadline) .font(.subheadline)
@@ -94,7 +94,7 @@ private struct FuelBoardLiveActivityCompactView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Cheapest \(context.attributes.fuel.displayName)") Text("Cheapest \(context.state.fuel.displayName)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(context.state.stationName) Text(context.state.stationName)
+9 -8
View File
@@ -5,20 +5,21 @@
// is deliberately NOT symlinked into FuelBoardTests/Sources the SPM test // is deliberately NOT symlinked into FuelBoardTests/Sources the SPM test
// target runs on macOS where ActivityKit does not exist. // target runs on macOS where ActivityKit does not exist.
// //
// The activity mirrors the app's TOP badge: the cheapest station selling the // The activity tracks the cheapest station for the fuel + radius configured
// app's selected fuel within the app's chosen distance radius. Tapping the // in Settings Live Activity (INDEPENDENT of the Stations-tab selection).
// activity opens Apple Maps driving directions to that station (maps://). // Everything dynamic lives in ContentState so updates including a fuel
// change apply to the running activity without needing to restart it.
// Tapping the activity opens Apple Maps driving directions (maps://).
import ActivityKit import ActivityKit
import Foundation import Foundation
/// Attributes are fixed at request time. Here they pin the FUEL being /// Attributes are fixed at request time. Kept empty: the fuel itself lives in
/// tracked; everything that can change while driving (station, price, /// ContentState so a fuel change in Settings updates the running activity
/// distance) lives in ContentState so updates don't need a new activity. /// in place instead of requiring an end + restart.
struct FuelBoardLiveActivityAttributes: ActivityAttributes { struct FuelBoardLiveActivityAttributes: ActivityAttributes {
var fuel: FuelType
public struct ContentState: Codable, Hashable { public struct ContentState: Codable, Hashable {
var fuel: FuelType
var stationID: String var stationID: String
var stationName: String var stationName: String
var brand: String var brand: String
+28
View File
@@ -263,6 +263,8 @@ struct FuelStore {
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
static let debugModeKey = "fuelboard.debugMode" // Bool hidden dev flag static let debugModeKey = "fuelboard.debugMode" // Bool hidden dev flag
static let liveActivityKey = "fuelboard.liveActivity" // Bool Live Activity toggle static let liveActivityKey = "fuelboard.liveActivity" // Bool Live Activity toggle
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv" static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv"
@@ -501,6 +503,32 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityKey) saveString(enabled ? "1" : "0", service: liveActivityKey)
} }
/// The fuel the Live Activity tracks independent of the Stations-tab
/// selection so the Lock Screen pill keeps its own target.
static func loadLiveActivityFuel() -> FuelType {
if let raw = loadString(service: liveActivityFuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveLiveActivityFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: liveActivityFuelKey)
}
/// The Live Activity's search radius in miles (5/10/15), converted to the
/// chosen unit at use same options as the Stations list.
static func loadLiveActivityRadiusMiles() -> Int {
if let raw = loadString(service: liveActivityRadiusKey), let value = Int(raw), stationRadiusOptions.contains(value) {
return value
}
return 5
}
static func saveLiveActivityRadiusMiles(_ miles: Int) {
saveString(String(miles), service: liveActivityRadiusKey)
}
// MARK: Refresh policy data is cached; the app only auto-refreshes // MARK: Refresh policy data is cached; the app only auto-refreshes
// twice a day (pull-to-refresh is the manual override). // twice a day (pull-to-refresh is the manual override).