Compare commits
7
Commits
63084322f6
...
276e0ff99e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276e0ff99e | ||
|
|
bed39956a3 | ||
|
|
b24b59df6f | ||
|
|
2495c7e47e | ||
|
|
923209b933 | ||
|
|
02ac8540f3 | ||
|
|
6f0fc43085 |
@@ -12,6 +12,8 @@ struct AlertsView: View {
|
|||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double // stored in km (monitor + storage)
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
@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).
|
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||||
@Binding var followsSearch: Bool
|
@Binding var followsSearch: Bool
|
||||||
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
||||||
@@ -131,7 +133,20 @@ struct AlertsView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Cheapest-station alerts", isOn: $enabled)
|
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
|
||||||
|
Picker("Fuel", selection: $favouriteDropFuel) {
|
||||||
|
ForEach(FuelType.allCases) { fuel in
|
||||||
|
Text(fuel.displayName).tag(fuel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Cheapest station", isOn: $enabled)
|
||||||
Picker("Fuel", selection: $fuel) {
|
Picker("Fuel", selection: $fuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
@@ -152,7 +167,7 @@ struct AlertsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Cheapest-station alerts")
|
Text("Proximity alerts")
|
||||||
} footer: {
|
} footer: {
|
||||||
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.")
|
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.")
|
||||||
}
|
}
|
||||||
@@ -165,7 +180,7 @@ struct AlertsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Live Activity", isOn: $liveActivityEnabled)
|
Toggle("Cheapest station", isOn: $liveActivityEnabled)
|
||||||
Picker("Fuel", selection: $liveActivityFuel) {
|
Picker("Fuel", selection: $liveActivityFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
|
|||||||
+122
-64
@@ -15,6 +15,8 @@ struct ContentView: View {
|
|||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@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 favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||||
|
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||||
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
@State private var alertsFollowsSearch: Bool = FuelStore.loadAlertsFollowsSearch()
|
||||||
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
@State private var liveActivityEnabled: Bool = FuelStore.loadLiveActivityEnabled()
|
||||||
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
@State private var liveActivityFuel: FuelType = FuelStore.loadLiveActivityFuel()
|
||||||
@@ -152,71 +154,34 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
configuredContent
|
||||||
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)
|
|
||||||
|
|
||||||
favouritesTab
|
private var configuredContent: AnyView {
|
||||||
.tabItem { Label("Favourites", systemImage: "star.fill") }
|
interactionObservedContent
|
||||||
.tag(1)
|
}
|
||||||
|
|
||||||
AlertsView(
|
private var presentedContent: AnyView {
|
||||||
enabled: $alertsEnabled,
|
AnyView(
|
||||||
radius: $alertsRadius,
|
mainContent
|
||||||
fuel: $alertsFuel,
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
followsSearch: $alertsFollowsSearch,
|
OnboardingView {
|
||||||
liveActivityEnabled: $liveActivityEnabled,
|
showOnboarding = false
|
||||||
liveActivityFuel: $liveActivityFuel,
|
}
|
||||||
liveActivityRadiusMiles: $liveActivityRadiusMiles,
|
}
|
||||||
liveActivityFollowsSearch: $liveActivityFollowsSearch,
|
.fullScreenCover(isPresented: $showWidgetMock) {
|
||||||
stationLimit: stationLimit,
|
WidgetMockScreen()
|
||||||
distanceUnit: distanceUnit,
|
}
|
||||||
monitoredCount: monitor.monitoredStationIDs.count,
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
lastAlert: monitor.lastAlert
|
StationMapView(request: request)
|
||||||
)
|
}
|
||||||
.tabItem { Label("Alerts", systemImage: "bell.fill") }
|
)
|
||||||
.tag(2)
|
}
|
||||||
|
|
||||||
settingsTab
|
private var lifecycleObservedContent: AnyView {
|
||||||
.tag(3)
|
AnyView(
|
||||||
}
|
presentedContent
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
.onAppear {
|
||||||
OnboardingView {
|
|
||||||
showOnboarding = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $showWidgetMock) {
|
|
||||||
WidgetMockScreen()
|
|
||||||
}
|
|
||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
|
||||||
StationMapView(request: request)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
// Launch-arg hooks for the screenshot/UI-test harness (same
|
// Launch-arg hooks for the screenshot/UI-test harness (same
|
||||||
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
// pattern as StationsView's `-showKeySheet`): `-tab <name>`
|
||||||
// opens the given tab; `-skipOnboarding` skips onboarding
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||||
@@ -379,7 +344,13 @@ struct ContentView: View {
|
|||||||
updateLiveActivity()
|
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.
|
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
@@ -425,6 +396,12 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
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
|
.onChange(of: liveActivityEnabled) { _, newValue in
|
||||||
// Toggling the Live Activity on starts it with the current best
|
// Toggling the Live Activity on starts it with the current best
|
||||||
// station; toggling off ends any running activity.
|
// station; toggling off ends any running activity.
|
||||||
@@ -464,10 +441,63 @@ struct ContentView: View {
|
|||||||
// so reading storage here could push the OLD style.
|
// so reading storage here could push the OLD style.
|
||||||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
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)
|
.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
|
/// The banner title for the bundled-snapshot case: date when the stamp
|
||||||
/// parsed, plain "Offline data" otherwise.
|
/// parsed, plain "Offline data" otherwise.
|
||||||
private func offlineTitle(date: String) -> String {
|
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
|
/// The Settings tab, extracted from `body` so the TabView expression stays
|
||||||
/// within the compiler's type-check budget.
|
/// within the compiler's type-check budget.
|
||||||
private var settingsTab: some View {
|
private var settingsTab: some View {
|
||||||
@@ -635,6 +686,7 @@ struct ContentView: View {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
|
let previousFavouriteSnapshots = FuelStore.loadFavouriteAlertSnapshots()
|
||||||
let fetched = try await FuelPriceProvider.active.fetchStations(
|
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||||||
near: location?.lat, lng: location?.lng,
|
near: location?.lat, lng: location?.lng,
|
||||||
fuel: selectedFuel,
|
fuel: selectedFuel,
|
||||||
@@ -654,6 +706,12 @@ struct ContentView: View {
|
|||||||
// channel shared on SideStore free), so stale star-time snapshots
|
// channel shared on SideStore free), so stale star-time snapshots
|
||||||
// would otherwise show old prices.
|
// would otherwise show old prices.
|
||||||
FuelStore.saveFavourites(refreshedFavourites)
|
FuelStore.saveFavourites(refreshedFavourites)
|
||||||
|
monitor.evaluateFavouritePriceDropAlert(
|
||||||
|
favourites: refreshedFavourites,
|
||||||
|
previousSnapshots: previousFavouriteSnapshots,
|
||||||
|
monitoredFuel: favouriteDropAlertsFuel,
|
||||||
|
enabled: favouriteDropAlertsEnabled
|
||||||
|
)
|
||||||
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))"
|
||||||
// Live data restored — any status banner no longer applies.
|
// Live data restored — any status banner no longer applies.
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ struct TieChoice {
|
|||||||
/// entering the winner's circle — not at the forecourt.
|
/// entering the winner's circle — not at the forecourt.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
|
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
private static let favouriteDropThresholdPence: Double = 1.0
|
||||||
@Published var monitoredStationIDs: [String] = []
|
@Published var monitoredStationIDs: [String] = []
|
||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@@ -218,6 +219,46 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
|
|
||||||
var isEnabled: Bool { enabled }
|
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.
|
/// Records a stage in the live alert trace, newest first, capped at 8.
|
||||||
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
|
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
|
||||||
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
|
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"
|
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
|
/// Adds a notification whose tap opens directions to `station`. The
|
||||||
/// station's coordinates ride in `userInfo` so the tap handler can route
|
/// 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
|
/// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ struct FavouriteEntry: Identifiable, Codable, Equatable {
|
|||||||
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persisted baseline for the per-fuel cheapest-favourite price-drop alert.
|
||||||
|
/// Stored separately from the favourites list so refresh-time comparison can
|
||||||
|
/// survive app relaunches without immediately alerting on first fetch.
|
||||||
|
struct FavouriteAlertSnapshot: Codable, Equatable {
|
||||||
|
let fuel: FuelType
|
||||||
|
let stationID: String
|
||||||
|
let stationName: String
|
||||||
|
let price: Double
|
||||||
|
}
|
||||||
|
|
||||||
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||||
case cheapest
|
case cheapest
|
||||||
case closest
|
case closest
|
||||||
@@ -318,6 +328,9 @@ struct FuelStore {
|
|||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||||
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
||||||
|
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
|
||||||
|
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
|
||||||
|
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
|
||||||
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||||
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
||||||
@@ -627,6 +640,24 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cheapest favourite for one fuel, preserving the user's stored order
|
||||||
|
/// on price ties. Used by the favourite price-drop alert so a tie does not
|
||||||
|
/// flap between equally-priced favourites.
|
||||||
|
static func cheapestFavourite(in favourites: [FavouriteEntry], fuel: FuelType) -> FavouriteEntry? {
|
||||||
|
var best: FavouriteEntry?
|
||||||
|
for entry in favourites where entry.fuel == fuel {
|
||||||
|
guard let price = entry.station.prices[fuel] else { continue }
|
||||||
|
guard let current = best, let currentPrice = current.station.prices[fuel] else {
|
||||||
|
best = entry
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if price < currentPrice {
|
||||||
|
best = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
|
/// Reorders ONE fuel's favourites within the global array (drag-and-drop in
|
||||||
/// the Favourites tab). The moved fuel's block stays at its original
|
/// the Favourites tab). The moved fuel's block stays at its original
|
||||||
/// position in the array; other fuels keep their relative order. The array
|
/// position in the array; other fuels keep their relative order. The array
|
||||||
@@ -710,6 +741,48 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
|
saveString(enabled ? "1" : "0", service: alertsFollowSearchKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Favourite price-drop alerts
|
||||||
|
|
||||||
|
static func loadFavouriteDropAlertsEnabled() -> Bool {
|
||||||
|
loadString(service: favouriteDropAlertsEnabledKey) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFavouriteDropAlertsEnabled(_ enabled: Bool) {
|
||||||
|
saveString(enabled ? "1" : "0", service: favouriteDropAlertsEnabledKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFavouriteDropAlertsFuel() -> FuelType {
|
||||||
|
if let raw = loadString(service: favouriteDropAlertsFuelKey), let fuel = FuelType(rawValue: raw) {
|
||||||
|
return fuel
|
||||||
|
}
|
||||||
|
return .e10
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFavouriteDropAlertsFuel(_ fuel: FuelType) {
|
||||||
|
saveString(fuel.rawValue, service: favouriteDropAlertsFuelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFavouriteAlertSnapshots() -> [FuelType: FavouriteAlertSnapshot] {
|
||||||
|
if let data = keychainData(service: favouriteAlertSnapshotsKey),
|
||||||
|
let stored = try? JSONDecoder().decode([String: FavouriteAlertSnapshot].self, from: data) {
|
||||||
|
var result: [FuelType: FavouriteAlertSnapshot] = [:]
|
||||||
|
for (key, value) in stored {
|
||||||
|
if let fuel = FuelType(rawValue: key) {
|
||||||
|
result[fuel] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFavouriteAlertSnapshots(_ snapshots: [FuelType: FavouriteAlertSnapshot]) {
|
||||||
|
let keyed = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.key.rawValue, $0.value) })
|
||||||
|
if let data = try? JSONEncoder().encode(keyed) {
|
||||||
|
writeKeychain(data: data, service: favouriteAlertSnapshotsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Live Activity
|
// MARK: Live Activity
|
||||||
|
|
||||||
static func loadLiveActivityEnabled() -> Bool {
|
static func loadLiveActivityEnabled() -> Bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user