import SwiftUI /// Alerts tab — enables the cheapest-station proximity alerts. The fuel being /// monitored is chosen HERE, independently of the Stations-tab selection: /// users can browse any fuel without re-targeting their alerts (and vice /// versa). The Live Activity section also lives here — it mirrors this page's /// shape (toggle + fuel + distance) so the two "notify me while driving" /// surfaces sit together. Geofence plumbing (count, 18-limit) lives in /// Settings → Debug; the user-facing page keeps a plain-language explainer /// plus a 0-stations hint. 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 alertMode: AlertMode @Binding var bestValueThresholdGBP: Double let bestValueConfigured: Bool @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 /// / Dynamic Island. Tracks its OWN fuel + radius (below) — independent of /// the Stations tab. @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 /// Whether the Live Activity mirrors the Stations-tab search distance. @Binding var liveActivityFollowsSearch: Bool /// Current Stations-tab search distance (miles) — the value "Follow search" /// mirrors. let stationLimit: Int let distanceUnit: DistanceUnit let monitoredCount: Int let lastAlert: String? let onCheckFavouriteDropNow: () -> Void /// The Stations-tab search distance shown in the user's unit, e.g. /// "5 miles" or "8 km" — for the "Follow search" rows. private var searchInUnitLabel: String { let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown))) } /// A miles value (5/10/15) shown truthfully in the user's unit, e.g. /// "5 miles" (miles unit) or "8 km" (km unit) — picker labels must match /// what the option really means. private func distanceLabel(forMiles miles: Int) -> String { let shown = distanceUnit.displayMiles(miles) return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown))) } /// Situational guidance for the currently-selected alert radius, shown /// under the picker so users know what each choice is best for. Labels /// mirror the picker row text (unit-aware) and explain the trade-off: /// smaller radii ping more often but later; larger radii ping rarely in /// town but earlier from afar. private var alertRadiusGuidance: String { switch alertChoice.wrappedValue { case .followSearch: return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance") case .fixed(let value): let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value))) let body: String switch value { case 1: body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance") case 2: body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance") case 3: body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance") case 5: body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance") default: body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance") } return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body) } } private enum AlertRadiusChoice: Hashable { case followSearch case fixed(Int) } /// The radius picker works in the user's chosen unit; the stored value /// stays km so ProximityMonitor and persistence never change. Options are /// snapped to the nearest picker value so any stored radius still selects. /// "Follow search" mirrors the Stations-tab distance (capped at 8 miles). private var alertChoice: Binding { Binding( get: { if followsSearch { return .followSearch } let inUnit = distanceUnit.fromKM(radius) let snapped = FuelStore.alertRadiusOptions .min(by: { abs(Double($0) - inUnit) < abs(Double($1) - inUnit) }) ?? 3 return .fixed(snapped) }, set: { choice in switch choice { case .followSearch: followsSearch = true case .fixed(let value): followsSearch = false radius = distanceUnit.toKM(Double(value)) } } ) } private enum LADistanceChoice: Hashable { case followSearch case fixed(Int) } private var laChoice: Binding { Binding( get: { if liveActivityFollowsSearch { return .followSearch } return .fixed(liveActivityRadiusMiles) }, set: { choice in switch choice { case .followSearch: liveActivityFollowsSearch = true case .fixed(let miles): liveActivityFollowsSearch = false liveActivityRadiusMiles = miles } } ) } var body: some View { NavigationStack { List { Section { Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled) Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) { ForEach(FuelType.allCases) { fuel in Text(fuel.displayName).tag(fuel) } } Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow) .disabled(!favouriteDropEnabled) } header: { Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title")) } footer: { Text(String(localized: "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.", comment: "Favourite price-drop alerts section footer")) } Section { Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled) Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) { ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in Text(mode.displayName).tag(mode) } } if !bestValueConfigured { Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint")) .font(.caption) .foregroundStyle(.secondary) } Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) { ForEach(FuelType.allCases) { fuel in Text(fuel.displayName).tag(fuel) } } Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) { Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch) ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value)) } } Text(alertRadiusGuidance) .font(.caption) .foregroundStyle(.secondary) if alertMode == .bestValue { Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) { ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in Text(FuelSavingsFormat.money(value)).tag(value) } } if !bestValueConfigured { Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint")) .font(.caption) .foregroundStyle(.secondary) } } if enabled && monitoredCount == 0 { Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint")) .font(.caption) .foregroundStyle(.secondary) } } header: { Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title")) } footer: { let modeDescription = alertMode == .bestValue ? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased()) : String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ 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.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased()) Text(modeDescription) } if enabled, let lastAlert { Section(String(localized: "Last alert", comment: "Last alert section title")) { Label(lastAlert, systemImage: "bell.badge.fill") .font(.footnote) } } Section { Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled) Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) { ForEach(FuelType.allCases) { fuel in Text(fuel.displayName).tag(fuel) } } Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) { Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch) ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles)) } } } header: { Text(String(localized: "Live Activity", comment: "Live Activity section title")) } footer: { Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer")) } } .navigationTitle(String(localized: "Alerts", comment: "Alerts tab title")) } } }