Add cheapest-favourite price-drop alerts
This commit is contained in:
@@ -12,6 +12,8 @@ struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||
@Binding var favouriteDropEnabled: Bool
|
||||
@Binding var favouriteDropFuel: FuelType
|
||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||
@Binding var followsSearch: Bool
|
||||
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
||||
@@ -157,6 +159,19 @@ struct AlertsView: View {
|
||||
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Cheapest-favourite price-drop alerts", isOn: $favouriteDropEnabled)
|
||||
Picker("Fuel", selection: $favouriteDropFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Favourite price-drop alerts")
|
||||
} footer: {
|
||||
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
|
||||
}
|
||||
|
||||
if enabled, let lastAlert {
|
||||
Section("Last alert") {
|
||||
Label(lastAlert, systemImage: "bell.badge.fill")
|
||||
|
||||
+122
-64
@@ -15,6 +15,8 @@ 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 favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
||||
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
||||
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
||||
@@ -152,71 +154,34 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch dataStatus {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBanner(
|
||||
icon: "wifi.slash",
|
||||
tint: .orange,
|
||||
title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
)
|
||||
case .connectionProblem:
|
||||
statusBanner(
|
||||
icon: "wifi.exclamationmark",
|
||||
tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
)
|
||||
case .live:
|
||||
EmptyView()
|
||||
}
|
||||
TabView(selection: $selectedTab) {
|
||||
stationsTab
|
||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||
.tag(0)
|
||||
configuredContent
|
||||
}
|
||||
|
||||
favouritesTab
|
||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||
.tag(1)
|
||||
private var configuredContent: AnyView {
|
||||
interactionObservedContent
|
||||
}
|
||||
|
||||
AlertsView(
|
||||
enabled: $alertsEnabled,
|
||||
radius: $alertsRadius,
|
||||
fuel: $alertsFuel,
|
||||
followsSearch: $alertsFollowsSearch,
|
||||
liveActivityEnabled: $liveActivityEnabled,
|
||||
liveActivityFuel: $liveActivityFuel,
|
||||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
||||
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
||||
stationLimit: stationLimit,
|
||||
distanceUnit: distanceUnit,
|
||||
monitoredCount: monitor.monitoredStationIDs.count,
|
||||
lastAlert: monitor.lastAlert
|
||||
)
|
||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||
.tag(2)
|
||||
private var presentedContent: AnyView {
|
||||
AnyView(
|
||||
mainContent
|
||||
.fullScreenCover(isPresented: $showOnboarding) {
|
||||
OnboardingView {
|
||||
showOnboarding = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showWidgetMock) {
|
||||
WidgetMockScreen()
|
||||
}
|
||||
.sheet(item: $monitor.pendingStationMap) { request in
|
||||
StationMapView(request: request)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
settingsTab
|
||||
.tag(3)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showOnboarding) {
|
||||
OnboardingView {
|
||||
showOnboarding = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showWidgetMock) {
|
||||
WidgetMockScreen()
|
||||
}
|
||||
.sheet(item: $monitor.pendingStationMap) { request in
|
||||
StationMapView(request: request)
|
||||
}
|
||||
.onAppear {
|
||||
private var lifecycleObservedContent: AnyView {
|
||||
AnyView(
|
||||
presentedContent
|
||||
.onAppear {
|
||||
// Launch-arg hooks for the screenshot/UI-test harness (same
|
||||
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
||||
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||
@@ -379,7 +344,13 @@ struct ContentView: View {
|
||||
updateLiveActivity()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedFuel) { _, _ in
|
||||
)
|
||||
}
|
||||
|
||||
private var interactionObservedContent: AnyView {
|
||||
AnyView(
|
||||
lifecycleObservedContent
|
||||
.onChange(of: selectedFuel) { _, _ in
|
||||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
@@ -425,6 +396,12 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
}
|
||||
.onChange(of: favouriteDropAlertsEnabled) { _, newValue in
|
||||
FuelStore.saveFavouriteDropAlertsEnabled(newValue)
|
||||
}
|
||||
.onChange(of: favouriteDropAlertsFuel) { _, newValue in
|
||||
FuelStore.saveFavouriteDropAlertsFuel(newValue)
|
||||
}
|
||||
.onChange(of: liveActivityEnabled) { _, newValue in
|
||||
// Toggling the Live Activity on starts it with the current best
|
||||
// station; toggling off ends any running activity.
|
||||
@@ -464,10 +441,63 @@ struct ContentView: View {
|
||||
// so reading storage here could push the OLD style.
|
||||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
||||
}
|
||||
} // VStack: status banner + TabView
|
||||
)
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
statusBannerView
|
||||
rootTabView
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBannerView: some View {
|
||||
switch dataStatus {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBanner(
|
||||
icon: "wifi.slash",
|
||||
tint: .orange,
|
||||
title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
)
|
||||
case .connectionProblem:
|
||||
statusBanner(
|
||||
icon: "wifi.exclamationmark",
|
||||
tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
)
|
||||
case .live:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var rootTabView: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
stationsTab
|
||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||
.tag(0)
|
||||
|
||||
favouritesTab
|
||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
||||
.tag(1)
|
||||
|
||||
alertsTab
|
||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
||||
.tag(2)
|
||||
|
||||
settingsTab
|
||||
.tag(3)
|
||||
}
|
||||
}
|
||||
|
||||
/// The banner title for the bundled-snapshot case: date when the stamp
|
||||
/// parsed, plain "Offline data" otherwise.
|
||||
private func offlineTitle(date: String) -> String {
|
||||
@@ -576,6 +606,27 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// The Alerts tab, extracted from `body` so the TabView expression stays
|
||||
/// within the compiler's type-check budget.
|
||||
private var alertsTab: some View {
|
||||
AlertsView(
|
||||
enabled: $alertsEnabled,
|
||||
radius: $alertsRadius,
|
||||
fuel: $alertsFuel,
|
||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||
followsSearch: $alertsFollowsSearch,
|
||||
liveActivityEnabled: $liveActivityEnabled,
|
||||
liveActivityFuel: $liveActivityFuel,
|
||||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
||||
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
||||
stationLimit: stationLimit,
|
||||
distanceUnit: distanceUnit,
|
||||
monitoredCount: monitor.monitoredStationIDs.count,
|
||||
lastAlert: monitor.lastAlert
|
||||
)
|
||||
}
|
||||
|
||||
/// The Settings tab, extracted from `body` so the TabView expression stays
|
||||
/// within the compiler's type-check budget.
|
||||
private var settingsTab: some View {
|
||||
@@ -635,6 +686,7 @@ struct ContentView: View {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
|
||||
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||||
near: location?.lat, lng: location?.lng,
|
||||
fuel: selectedFuel,
|
||||
@@ -654,6 +706,12 @@ struct ContentView: View {
|
||||
// channel shared on SideStore free), so stale star-time snapshots
|
||||
// would otherwise show old prices.
|
||||
FuelStore.saveFavourites(refreshedFavourites)
|
||||
monitor.evaluateFavouritePriceDropAlert(
|
||||
favourites: refreshedFavourites,
|
||||
previousSnapshots: previousFavouriteSnapshots,
|
||||
monitoredFuel: favouriteDropAlertsFuel,
|
||||
enabled: favouriteDropAlertsEnabled
|
||||
)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||
// Live data restored — any status banner no longer applies.
|
||||
|
||||
@@ -79,6 +79,7 @@ struct TieChoice {
|
||||
/// entering the winner's circle — not at the forecourt.
|
||||
@MainActor
|
||||
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
|
||||
private static let favouriteDropThresholdPence: Double = 1.0
|
||||
@Published var monitoredStationIDs: [String] = []
|
||||
@Published var lastAlert: String?
|
||||
@Published private(set) var lastTestResult: String?
|
||||
@@ -218,6 +219,46 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
|
||||
var isEnabled: Bool { enabled }
|
||||
|
||||
/// Compares the latest refreshed favourites against the persisted cheapest-
|
||||
/// favourite baseline for one fuel and schedules a notification when the
|
||||
/// winner changes or the current winner drops by a meaningful amount.
|
||||
func evaluateFavouritePriceDropAlert(
|
||||
favourites: [FavouriteEntry],
|
||||
previousSnapshots: [FuelType: FavouriteAlertSnapshot],
|
||||
monitoredFuel: FuelType,
|
||||
enabled: Bool
|
||||
) {
|
||||
var snapshots = previousSnapshots
|
||||
guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel),
|
||||
let currentPrice = current.station.prices[monitoredFuel] else {
|
||||
snapshots.removeValue(forKey: monitoredFuel)
|
||||
FuelStore.saveFavouriteAlertSnapshots(snapshots)
|
||||
return
|
||||
}
|
||||
|
||||
let currentSnapshot = FavouriteAlertSnapshot(
|
||||
fuel: monitoredFuel,
|
||||
stationID: current.station.id,
|
||||
stationName: current.station.name,
|
||||
price: currentPrice
|
||||
)
|
||||
|
||||
defer {
|
||||
snapshots[monitoredFuel] = currentSnapshot
|
||||
FuelStore.saveFavouriteAlertSnapshots(snapshots)
|
||||
}
|
||||
|
||||
guard enabled else { return }
|
||||
guard let previous = previousSnapshots[monitoredFuel] else { return }
|
||||
|
||||
let winnerChanged = previous.stationID != currentSnapshot.stationID
|
||||
let priceDropped = previous.stationID == currentSnapshot.stationID
|
||||
&& currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence
|
||||
|
||||
guard winnerChanged || priceDropped else { return }
|
||||
fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged)
|
||||
}
|
||||
|
||||
/// Records a stage in the live alert trace, newest first, capped at 8.
|
||||
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
|
||||
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
|
||||
@@ -452,6 +493,31 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
|
||||
}
|
||||
|
||||
private func fireFavouriteDropAlert(
|
||||
current: FavouriteEntry,
|
||||
previous: FavouriteAlertSnapshot,
|
||||
price: Double,
|
||||
winnerChanged: Bool
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
let fuel = current.fuel
|
||||
let previousPriceText = String(format: "%.1fp", previous.price)
|
||||
let currentPriceText = String(format: "%.1fp", price)
|
||||
|
||||
if winnerChanged {
|
||||
content.title = "New cheapest \(fuel.displayName.lowercased()) favourite"
|
||||
content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)."
|
||||
} else {
|
||||
content.title = "Cheapest favourite just dropped"
|
||||
content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())."
|
||||
}
|
||||
|
||||
addAlertRequest(content: content, station: current.station)
|
||||
logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)")
|
||||
lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)"
|
||||
}
|
||||
|
||||
/// Adds a notification whose tap opens directions to `station`. The
|
||||
/// station's coordinates ride in `userInfo` so the tap handler can route
|
||||
/// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
|
||||
|
||||
Reference in New Issue
Block a user