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 short descriptions that follow /// the selected alert type and radius, 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 /// Read-only here: the threshold is edited in Settings → Fuel Savings, where /// the fill size and MPG that decide the saving live. Read back so the /// alert's own rule is visible without leaving the tab. let 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))) } /// The distance the geofence actually caps "Follow search" at, in miles /// (FuelStore stores the cap in km so the monitor and storage are unit-free). private var followSearchCapMiles: Int { Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded()) } /// What the selected alert type actually does. Swapped with the picker so /// the copy can never describe the wrong mode. private var alertModeDescription: String { switch alertMode { case .cheapest: return String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description") case .bestValue: return String(localized: "Get notified when you're near a station that could save you money after travel costs.", comment: "Best Value proximity alert description") } } /// The radius the selected option really monitors, stated in the user's /// unit — e.g. "Get alerts when you're within 2 miles of a matching /// station." "Follow search" mirrors the Stations-tab distance, which the /// geofence caps; said plainly, and only when the search distance really /// exceeds the cap, so the sentence never contradicts the picker row. private var alertRadiusDescription: String { switch alertChoice.wrappedValue { case .followSearch: let capped = stationLimit > followSearchCapMiles let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit) if capped { return String(format: String(localized: "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).", comment: "Proximity alert radius description, follow search capped"), label) } return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label) case .fixed(let value): let label = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value))) return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label) } } 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) Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description")) .font(.footnote) .foregroundStyle(.secondary) } header: { Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title")) } 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(.footnote) .foregroundStyle(.secondary) } Text(alertModeDescription) .font(.footnote) .foregroundStyle(.secondary) if alertMode == .bestValue { // The threshold is edited in Settings → Fuel Savings: it // labels station rows BEST VALUE as well as gating this // alert, so it lives with the fill size and MPG that // produce the saving. Read back here, not offered twice. Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP))) .font(.footnote) .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(alertRadiusDescription) .font(.footnote) .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(.footnote) .foregroundStyle(.secondary) } Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note")) .font(.footnote) .foregroundStyle(.secondary) } header: { Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title")) } 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)) } } if liveActivityFollowsSearch { Text(String(localized: "Uses the same distance as your station search.", comment: "Live Activity distance description, follow search")) .font(.footnote) .foregroundStyle(.secondary) } Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description")) .font(.footnote) .foregroundStyle(.secondary) } header: { Text(String(localized: "Live Activity", comment: "Live Activity section title")) } } .navigationTitle(String(localized: "Alerts", comment: "Alerts tab title")) } } }