Cache-first refresh policy: auto-fetch at most twice a day (12h gate), no fetch on launch/foreground/location/fuel-switch, pull-to-refresh forces; geofences still follow location

This commit is contained in:
FuelBoard Contributor
2026-08-11 17:43:11 +01:00
parent 5a08a3a2fa
commit 8230daa249
3 changed files with 46 additions and 7 deletions
+17 -7
View File
@@ -87,7 +87,8 @@ struct ContentView: View {
location: location, location: location,
radiusKM: alertsRadius, radiusKM: alertsRadius,
favouriteIDs: favouriteIDs, favouriteIDs: favouriteIDs,
onToggleFavourite: toggleFavourite onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
) )
.tabItem { Label("Stations", systemImage: "fuelpump.fill") } .tabItem { Label("Stations", systemImage: "fuelpump.fill") }
@@ -113,6 +114,7 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius) fuel: selectedFuel, radiusKM: alertsRadius)
monitor.setEnabled(alertsEnabled) monitor.setEnabled(alertsEnabled)
// Refresh only when the cache is stale (twice-a-day policy).
Task { await refresh() } Task { await refresh() }
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
@@ -120,7 +122,7 @@ struct ContentView: View {
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius) fuel: selectedFuel, radiusKM: alertsRadius)
Task { await refresh() } // No network fetch on foreground pull-to-refresh is the override.
} else { } else {
locationManager.stopForegroundTracking() locationManager.stopForegroundTracking()
} }
@@ -130,13 +132,15 @@ struct ContentView: View {
location = newLocation location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng) FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
Task { await refresh() } // Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: selectedFuel, radiusKM: alertsRadius)
} }
} }
.onChange(of: selectedFuel) { _, _ in .onChange(of: selectedFuel) { _, _ in
// Re-fetch so the station set matches the selected fuel (relay // No re-fetch needed one response carries E5/E10/DIESEL prices.
// filters by grade); all prices still come back in one response. WidgetCenter.shared.reloadAllTimelines()
Task { await refresh() }
} }
.onChange(of: alertsEnabled) { _, newValue in .onChange(of: alertsEnabled) { _, newValue in
FuelStore.saveAlertsEnabled(newValue) FuelStore.saveAlertsEnabled(newValue)
@@ -166,13 +170,19 @@ struct ContentView: View {
fuel: selectedFuel, radiusKM: alertsRadius) fuel: selectedFuel, radiusKM: alertsRadius)
} }
private func refresh() async { /// Fetches fresh prices, but only when the cache is stale unless
/// `force` is true (pull-to-refresh is the manual override).
private func refresh(force: Bool = false) async {
if !force, FuelStore.isCacheFresh {
return // data already fresh skip network entirely
}
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
do { do {
let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel) let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel)
stations = fetched stations = fetched
FuelStore.saveStations(fetched) FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
} catch { } catch {
+5
View File
@@ -14,6 +14,7 @@ struct StationsView: View {
let radiusKM: Double let radiusKM: Double
let favouriteIDs: Set<String> let favouriteIDs: Set<String>
var onToggleFavourite: (FuelStation) -> Void = { _ in } var onToggleFavourite: (FuelStation) -> Void = { _ in }
var onRefresh: () async -> Void = {}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -130,6 +131,10 @@ struct StationsView: View {
} }
} }
} }
.refreshable {
// Manual override for the twice-a-day cache policy.
await onRefresh()
}
.navigationTitle("FuelBoard") .navigationTitle("FuelBoard")
} }
} }
+24
View File
@@ -144,6 +144,7 @@ struct FuelStore {
static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
// MARK: Stations // MARK: Stations
@@ -280,6 +281,29 @@ struct FuelStore {
saveString(String(radius), service: alertsRadiusKey) saveString(String(radius), service: alertsRadiusKey)
} }
// MARK: Refresh policy data is cached; the app only auto-refreshes
// twice a day (pull-to-refresh is the manual override).
static let refreshInterval: TimeInterval = 12 * 60 * 60
static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
}
/// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool {
guard let last = loadLastRefresh() else { return false }
return Date().timeIntervalSince(last) < refreshInterval
}
// MARK: Low-level keychain helpers // MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? { private static func keychainData(service: String) -> Data? {