From ebda21436d53addf4159e9f96f4a4acf2d08566c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sun, 30 Aug 2026 18:57:57 +0100 Subject: [PATCH] Restore smart settings and sync watch favourites --- Config/App-Info.plist | 5 + FuelBoard/ContentView.swift | 8 ++ FuelBoard/FuelBoardApp.swift | 9 ++ FuelBoard/SettingsView.swift | 27 +++++ FuelBoard/SmartDataRefresh.swift | 89 +++++++++++++++ FuelBoard/WatchSyncManager.swift | 106 ++++++++++++++++++ FuelBoardWatch Watch App/ContentView.swift | 10 ++ .../FuelBoardWatchApp.swift | 4 + .../WatchSyncManager.swift | 84 ++++++++++++++ Shared/DataRefreshMode.swift | 24 ++++ Shared/FuelStore.swift | 42 ++++++- 11 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 FuelBoard/SmartDataRefresh.swift create mode 100644 FuelBoard/WatchSyncManager.swift create mode 100644 FuelBoardWatch Watch App/WatchSyncManager.swift create mode 100644 Shared/DataRefreshMode.swift diff --git a/Config/App-Info.plist b/Config/App-Info.plist index 155c3e2..4ced56f 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -41,8 +41,13 @@ FuelBoard uses your location to find the cheapest nearby petrol stations. NSSupportsLiveActivities + BGTaskSchedulerPermittedIdentifiers + + com.apt.fuelboard.smart-refresh + UIBackgroundModes + fetch location UILaunchScreen diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 39534e3..dc9b915 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -16,6 +16,7 @@ struct ContentView: View { @State private var stations: [FuelStation] = FuelStore.loadStations() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() + @State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode() @State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @@ -275,6 +276,7 @@ struct ContentView: View { } if !favs.isEmpty { FuelStore.saveFavourites(favs) + WatchSyncManager.shared.pushSnapshot() } } if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { @@ -396,6 +398,7 @@ struct ContentView: View { if let newLocation { location = newLocation FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() // Geofences follow the user's position, but the station list is // NOT re-fetched on every movement (cached, twice-a-day policy). @@ -414,6 +417,7 @@ struct ContentView: View { .onChange(of: selectedFuel) { _, _ in // No re-fetch needed — one response carries E5/E10/DIESEL prices. WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } .onChange(of: stationLimit) { _, newValue in // Distance filter is LOCAL math now — the cache holds the full-UK @@ -790,6 +794,7 @@ struct ContentView: View { tipStore: tipStore, distanceUnit: $distanceUnit, priceDisplayStyle: $priceDisplayStyle, + dataRefreshMode: $dataRefreshMode, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, testAlertResult: monitor.lastTestResult, @@ -818,6 +823,7 @@ struct ContentView: View { favourites.append(key) } FuelStore.saveFavourites(favourites) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) @@ -829,6 +835,7 @@ struct ContentView: View { private func reorderFavourites(_ newOrder: [FavouriteEntry]) { favourites = newOrder FuelStore.saveFavourites(favourites) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) @@ -860,6 +867,7 @@ struct ContentView: View { if force, FuelStore.hasPendingWatchRefreshRequest { FuelStore.markWatchRefreshHandled() } + WatchSyncManager.shared.pushSnapshot() // Persist envelope metadata (source, station count, GOV.UK // dataset update time) for the Settings → About section — the // live chain records whichever leg served the fetch. diff --git a/FuelBoard/FuelBoardApp.swift b/FuelBoard/FuelBoardApp.swift index bd508f7..9d7473d 100644 --- a/FuelBoard/FuelBoardApp.swift +++ b/FuelBoard/FuelBoardApp.swift @@ -1,9 +1,12 @@ import ActivityKit +import BackgroundTasks import SwiftUI @main struct FuelBoardApp: App { init() { + WatchSyncManager.shared.activate() + SmartDataRefreshScheduler.register() #if DEBUG // QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a // Live Activity with a long station name so the Lock Screen / island @@ -21,6 +24,12 @@ struct FuelBoardApp: App { WindowGroup { ContentView() .onOpenURL(perform: handleOpenURL) + .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in + WatchSyncManager.shared.pushSnapshot() + } + } + .backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) { + await SmartDataRefreshCoordinator.runBackgroundProbe() } } diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 3f0744f..7fe3080 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @ObservedObject var tipStore: TipStore @Binding var distanceUnit: DistanceUnit @Binding var priceDisplayStyle: PriceDisplayStyle + @Binding var dataRefreshMode: DataRefreshMode /// 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 @@ -103,6 +104,7 @@ struct SettingsView: View { .onChange(of: distanceUnit) { _, newValue in FuelStore.saveDistanceUnit(newValue) WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } Picker("Price display", selection: $priceDisplayStyle) { ForEach(PriceDisplayStyle.allCases) { style in @@ -113,6 +115,7 @@ struct SettingsView: View { .onChange(of: priceDisplayStyle) { _, newValue in FuelStore.savePriceDisplayStyle(newValue) WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } } header: { Text("Units") @@ -120,6 +123,30 @@ struct SettingsView: View { Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).") } + Section { + Picker("Data checking", selection: $dataRefreshMode) { + ForEach(DataRefreshMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .onChange(of: dataRefreshMode) { _, newValue in + FuelStore.saveDataRefreshMode(newValue) + SmartDataRefreshScheduler.scheduleNextIfNeeded() + WatchSyncManager.shared.pushSnapshot() + } + VStack(alignment: .leading, spacing: 8) { + Text(dataRefreshMode.summary) + Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.") + .foregroundStyle(.secondary) + } + .font(.footnote) + } header: { + Text("Data checking") + } footer: { + Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.") + } + Section { Button { onShowOnboarding() diff --git a/FuelBoard/SmartDataRefresh.swift b/FuelBoard/SmartDataRefresh.swift new file mode 100644 index 0000000..8d83d2a --- /dev/null +++ b/FuelBoard/SmartDataRefresh.swift @@ -0,0 +1,89 @@ +import BackgroundTasks +import Foundation +import WidgetKit + +enum SmartDataRefreshScheduler { + static let taskIdentifier = "com.apt.fuelboard.smart-refresh" + + static func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let task = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(task) + } + } + + static func scheduleNextIfNeeded() { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) + guard FuelStore.loadDataRefreshMode() == .smart else { return } + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + #if DEBUG + print("SMART-REFRESH schedule failed: \(error)") + #endif + } + } + + private static func handle(_ task: BGAppRefreshTask) { + scheduleNextIfNeeded() + let refreshTask = Task { + let success = await SmartDataRefreshCoordinator.runBackgroundProbe() + task.setTaskCompleted(success: success) + } + task.expirationHandler = { + refreshTask.cancel() + } + } +} + +enum SmartDataRefreshCoordinator { + @discardableResult + static func runBackgroundProbe(now: Date = Date()) async -> Bool { + guard FuelStore.loadDataRefreshMode() == .smart else { return true } + guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else { + return true + } + FuelStore.saveLastSmartProbe(now) + guard !Task.isCancelled else { return false } + + guard let latest = await FuelHistoryStore.fetchLatest() else { + return false + } + + let latestDay = latest.availableTo ?? latest.date + let latestUpdated = latest.dataUpdated + let cachedDay = MirrorFuelProvider.loadDumpCache()?.day + let savedUpdated = FuelStore.loadDataUpdated() + let shouldFetchFullDump = FuelStore.loadStations().isEmpty + || !FuelStore.isCacheFresh(now: now) + || (latestDay != nil && latestDay != cachedDay) + || (latestUpdated != nil && latestUpdated != savedUpdated) + + guard shouldFetchFullDump, !Task.isCancelled else { return true } + + do { + let fetched = try await FuelPriceProvider.active.fetchStations( + near: nil, + lng: nil, + fuel: FuelStore.loadSelectedFuel(), + radiusKM: nil + ) + guard !Task.isCancelled else { return false } + FuelStore.saveStations(fetched) + FuelStore.saveLastRefresh(now) + await WatchSyncManager.shared.pushSnapshot() + if let meta = LiveChainProvider.latestMeta { + FuelStore.saveRelayMeta(meta) + } + WidgetCenter.shared.reloadAllTimelines() + return true + } catch { + return false + } + } +} diff --git a/FuelBoard/WatchSyncManager.swift b/FuelBoard/WatchSyncManager.swift new file mode 100644 index 0000000..d648afc --- /dev/null +++ b/FuelBoard/WatchSyncManager.swift @@ -0,0 +1,106 @@ +import Foundation +import WatchConnectivity + +extension Notification.Name { + static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested") +} + +@MainActor +final class WatchSyncManager: NSObject, WCSessionDelegate { + static let shared = WatchSyncManager() + + private var pendingSnapshotPush = false + + private override init() { + super.init() + } + + func activate() { + guard WCSession.isSupported() else { return } + pendingSnapshotPush = true + let session = WCSession.default + if session.delegate !== self { + session.delegate = self + } + session.activate() + } + + func pushSnapshot() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated else { + pendingSnapshotPush = true + return + } + do { + try session.updateApplicationContext(snapshotContext()) + pendingSnapshotPush = false + } catch { + print("WATCH-SYNC push failed: \(error.localizedDescription)") + } + } + + private func snapshotContext() -> [String: Any] { + var context: [String: Any] = [:] + + if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) { + context[FuelStore.favouritesKey] = favourites + } + + context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue + context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue + context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue + + if let lastRefresh = FuelStore.loadLastRefresh() { + context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970) + } + + if let handled = FuelStore.loadWatchRefreshHandled() { + context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970) + } + + if let location = FuelStore.loadLocationWithDate() { + context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)" + } + + return context + } + + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + if let error { + print("WATCH-SYNC activation failed: \(error.localizedDescription)") + return + } + Task { @MainActor in + if activationState == .activated, self.pendingSnapshotPush { + self.pushSnapshot() + } + } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + Task { @MainActor in + WCSession.default.activate() + } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + handleIncomingRefreshRequest(userInfo) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + handleIncomingRefreshRequest(message) + } + + private nonisolated func handleIncomingRefreshRequest(_ payload: [String: Any]) { + guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String, + let timestamp = TimeInterval(raw) else { return } + let date = Date(timeIntervalSince1970: timestamp) + Task { @MainActor in + FuelStore.requestWatchRefresh(date) + NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil) + } + } +} diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift index 8bdbcbc..0bc155a 100644 --- a/FuelBoardWatch Watch App/ContentView.swift +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -280,6 +280,7 @@ private enum WatchFuelCache { } struct ContentView: View { + @Environment(\.scenePhase) private var scenePhase @State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel() @State private var refreshState: WatchRefreshState = .noData @State private var favourites: [WatchFavouriteEntry] = [] @@ -297,6 +298,14 @@ struct ContentView: View { } .tabViewStyle(.verticalPage) .onAppear(perform: reloadFromCache) + .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in + reloadFromCache() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + reloadFromCache() + } + } } @ViewBuilder @@ -499,6 +508,7 @@ struct ContentView: View { refreshState = .checking let requestDate = Date() WatchFuelCache.requestRefresh(requestDate) + WatchSyncManager.shared.requestRefresh(at: requestDate) Task { for _ in 0..<12 { try? await Task.sleep(for: .seconds(1)) diff --git a/FuelBoardWatch Watch App/FuelBoardWatchApp.swift b/FuelBoardWatch Watch App/FuelBoardWatchApp.swift index 93b4e0c..75a8e7a 100644 --- a/FuelBoardWatch Watch App/FuelBoardWatchApp.swift +++ b/FuelBoardWatch Watch App/FuelBoardWatchApp.swift @@ -2,6 +2,10 @@ import SwiftUI @main struct FuelBoardWatchApp: App { + init() { + WatchSyncManager.shared.activate() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/FuelBoardWatch Watch App/WatchSyncManager.swift b/FuelBoardWatch Watch App/WatchSyncManager.swift new file mode 100644 index 0000000..0bce7db --- /dev/null +++ b/FuelBoardWatch Watch App/WatchSyncManager.swift @@ -0,0 +1,84 @@ +import Foundation +import WatchConnectivity + +extension Notification.Name { + static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate") +} + +final class WatchSyncManager: NSObject, WCSessionDelegate { + static let shared = WatchSyncManager() + + private let suiteName = "group.com.apt.fuelboard" + + private override init() { + super.init() + } + + func activate() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + if session.delegate !== self { + session.delegate = self + } + session.activate() + } + + func requestRefresh(at date: Date = Date()) { + guard WCSession.isSupported() else { return } + let raw = String(date.timeIntervalSince1970) + let payload = ["fuelboard.watchRefreshRequest": raw] + let session = WCSession.default + + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(payload) + } + } + + private func sharedDefaults() -> UserDefaults? { + UserDefaults(suiteName: suiteName) + } + + private func storeSnapshot(_ payload: [String: Any]) { + guard let defaults = sharedDefaults() else { return } + + if let favourites = payload["fuelboard.favourites"] as? Data { + defaults.set(favourites, forKey: "fuelboard.favourites") + } + if let fuel = payload["fuelboard.selectedFuel"] as? String { + defaults.set(fuel, forKey: "fuelboard.selectedFuel") + } + if let distanceUnit = payload["fuelboard.distanceUnit"] as? String { + defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit") + } + if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String { + defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle") + } + if let location = payload["fuelboard.lastLocation"] as? String { + defaults.set(location, forKey: "fuelboard.lastLocation") + } + if let lastRefresh = payload["fuelboard.lastRefresh"] as? String { + defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh") + } + if let handled = payload["fuelboard.watchRefreshHandled"] as? String { + defaults.set(handled, forKey: "fuelboard.watchRefreshHandled") + } + + NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil) + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + if let error { + print("WATCH-SYNC activation failed: \(error.localizedDescription)") + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + storeSnapshot(applicationContext) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + storeSnapshot(userInfo) + } +} diff --git a/Shared/DataRefreshMode.swift b/Shared/DataRefreshMode.swift new file mode 100644 index 0000000..6179099 --- /dev/null +++ b/Shared/DataRefreshMode.swift @@ -0,0 +1,24 @@ +import Foundation + +enum DataRefreshMode: String, CaseIterable, Identifiable { + case standard + case smart + + var id: String { rawValue } + + var displayName: String { + switch self { + case .standard: return "Standard" + case .smart: return "Smart" + } + } + + var summary: String { + switch self { + case .standard: + return "Saves battery. Refreshes price data up to twice daily." + case .smart: + return "Checks for newer data more often in the background and refreshes full prices only when an update is available." + } + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 659ab54..51ff958 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -370,6 +370,8 @@ struct FuelStore { static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) + static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value + static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970) static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970) static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970) static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" @@ -863,10 +865,24 @@ struct FuelStore { saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) } - // MARK: Refresh policy — data is cached; the app only auto-refreshes - // twice a day (pull-to-refresh is the manual override). + // MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps + // the same full-dump freshness cap but may probe the tiny mirror pointer + // more often in the background and only refresh the full dump if it changed. static let refreshInterval: TimeInterval = 12 * 60 * 60 + static let smartProbeInterval: TimeInterval = 4 * 60 * 60 + + static func loadDataRefreshMode() -> DataRefreshMode { + if let raw = loadString(service: dataRefreshModeKey), + let mode = DataRefreshMode(rawValue: raw) { + return mode + } + return .standard + } + + static func saveDataRefreshMode(_ mode: DataRefreshMode) { + saveString(mode.rawValue, service: dataRefreshModeKey) + } static func loadLastRefresh() -> Date? { if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) { @@ -879,6 +895,17 @@ struct FuelStore { saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) } + static func loadLastSmartProbe() -> Date? { + if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) { + return Date(timeIntervalSince1970: ts) + } + return nil + } + + static func saveLastSmartProbe(_ date: Date = Date()) { + saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey) + } + static func requestWatchRefresh(_ date: Date = Date()) { saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey) } @@ -959,8 +986,17 @@ struct FuelStore { /// True when the cached data is fresh enough that a scheduled auto-refresh /// should be skipped (twice-a-day policy). static var isCacheFresh: Bool { + isCacheFresh(now: Date()) + } + + static func isCacheFresh(now: Date) -> Bool { guard let last = loadLastRefresh() else { return false } - return Date().timeIntervalSince(last) < refreshInterval + return now.timeIntervalSince(last) < refreshInterval + } + + static func isSmartProbeDue(now: Date = Date()) -> Bool { + guard let last = loadLastSmartProbe() else { return true } + return now.timeIntervalSince(last) >= smartProbeInterval } // MARK: Onboarding — the app shows the intro screen on first launch only