253 lines
14 KiB
Swift
253 lines
14 KiB
Swift
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<AlertRadiusChoice> {
|
|
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<LADistanceChoice> {
|
|
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"))
|
|
}
|
|
}
|
|
}
|