Compare commits
+18
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
scene (CPListTemplate) for navigation deep-links.
|
scene (CPListTemplate) for navigation deep-links.
|
||||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||||||
|
- [ ] **Restore Best Value proximity alerts after fixing evaluation parity** —
|
||||||
|
the option is intentionally removed from the Alerts tab and legacy selections
|
||||||
|
fall back to `Cheapest station` until this is implemented. Root cause: the live
|
||||||
|
geofence path calculates journeys from the entered station, making that station
|
||||||
|
its own zero-saving baseline and therefore unable to clear any positive minimum
|
||||||
|
saving, while Test alert calculates from the user's location and can pass.
|
||||||
|
Future implementation must obtain a fresh user fix on region entry; fetch,
|
||||||
|
radius-filter, choose the closest eligible baseline and calculate every journey
|
||||||
|
from that user location; then notify only when the entered station is the best
|
||||||
|
qualifying candidate. Real and Test alerts must call one shared pure evaluator.
|
||||||
|
Apply one disclosed qualification rule everywhere (real alert, Test alert and
|
||||||
|
BEST VALUE badge): remove the live path's hidden extra £0.50 estimate margin,
|
||||||
|
compare the same penny-rounded amount shown to the user while retaining raw
|
||||||
|
precision for ranking, and pass the current threshold explicitly into station
|
||||||
|
rows instead of reading persistence per row. Regression coverage: entered
|
||||||
|
station can qualify, a better nearby candidate blocks it, real/Test parity,
|
||||||
|
exact and display-rounding boundaries, estimated/confirmed parity, immediate
|
||||||
|
threshold changes, and below-threshold rejection.
|
||||||
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
||||||
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
||||||
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
||||||
|
|||||||
@@ -533,7 +533,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -826,7 +826,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -851,7 +851,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -900,7 +900,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 26;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
|
|||||||
+110
-62
@@ -6,15 +6,12 @@ import SwiftUI
|
|||||||
/// versa). The Live Activity section also lives here — it mirrors this page's
|
/// versa). The Live Activity section also lives here — it mirrors this page's
|
||||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||||
/// Settings → Debug; the user-facing page keeps a plain-language explainer
|
/// Settings → Debug; the user-facing page keeps short descriptions of the
|
||||||
/// plus a 0-stations hint.
|
/// cheapest-station rule and radius, plus a 0-stations hint.
|
||||||
struct AlertsView: View {
|
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 alertMode: AlertMode
|
|
||||||
@Binding var bestValueThresholdGBP: Double
|
|
||||||
let bestValueConfigured: Bool
|
|
||||||
@Binding var favouriteDropEnabled: Bool
|
@Binding var favouriteDropEnabled: Bool
|
||||||
@Binding var favouriteDropFuel: FuelType
|
@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).
|
||||||
@@ -52,31 +49,29 @@ struct AlertsView: View {
|
|||||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Situational guidance for the currently-selected alert radius, shown
|
/// The distance the geofence actually caps "Follow search" at, in miles
|
||||||
/// under the picker so users know what each choice is best for. Labels
|
/// (FuelStore stores the cap in km so the monitor and storage are unit-free).
|
||||||
/// mirror the picker row text (unit-aware) and explain the trade-off:
|
private var followSearchCapMiles: Int {
|
||||||
/// smaller radii ping more often but later; larger radii ping rarely in
|
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||||
/// town but earlier from afar.
|
}
|
||||||
private var alertRadiusGuidance: String {
|
|
||||||
|
/// 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 {
|
switch alertChoice.wrappedValue {
|
||||||
case .followSearch:
|
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")
|
let capped = stationLimit > followSearchCapMiles
|
||||||
case .fixed(let value):
|
let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
|
||||||
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
if capped {
|
||||||
let body: String
|
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)
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,74 +128,110 @@ struct AlertsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The radius currently selected, in the user's unit — shown by the disabled
|
||||||
|
/// row in place of the picker.
|
||||||
|
private var alertRadiusValueLabel: String {
|
||||||
|
switch alertChoice.wrappedValue {
|
||||||
|
case .followSearch:
|
||||||
|
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
|
||||||
|
case .fixed(let value):
|
||||||
|
return String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var laDistanceValueLabel: String {
|
||||||
|
switch laChoice.wrappedValue {
|
||||||
|
case .followSearch:
|
||||||
|
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
|
||||||
|
case .fixed(let miles):
|
||||||
|
return distanceLabel(forMiles: miles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An option row that belongs to a toggle. When the toggle is off the control
|
||||||
|
/// is replaced by a static value row. The enclosing dependent group applies
|
||||||
|
/// FuelBoard's shared disabled interaction and appearance once.
|
||||||
|
@ViewBuilder
|
||||||
|
private func optionRow<Content: View>(_ title: String,
|
||||||
|
value: String,
|
||||||
|
isEnabled: Bool,
|
||||||
|
@ViewBuilder picker: () -> Content) -> some View {
|
||||||
|
if isEnabled {
|
||||||
|
picker()
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||||
|
Group {
|
||||||
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
|
value: favouriteDropFuel.displayName,
|
||||||
|
isEnabled: favouriteDropEnabled) {
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
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)
|
||||||
|
}
|
||||||
|
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
Text(String(localized: "Favourite price alerts", comment: "Favourite price 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 {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
Group {
|
||||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
|
||||||
Text(mode.displayName).tag(mode)
|
.font(.footnote)
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
|
value: fuel.displayName,
|
||||||
|
isEnabled: enabled) {
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
|
||||||
|
value: alertRadiusValueLabel,
|
||||||
|
isEnabled: enabled) {
|
||||||
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
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)
|
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
|
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(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(alertRadiusGuidance)
|
}
|
||||||
.font(.caption)
|
Text(alertRadiusDescription)
|
||||||
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
if enabled && monitoredCount == 0 {
|
||||||
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
||||||
.font(.caption)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
||||||
|
}
|
||||||
|
.fuelBoardDisabledState(isEnabled: enabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
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 {
|
if enabled, let lastAlert {
|
||||||
@@ -212,21 +243,38 @@ struct AlertsView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||||
|
Group {
|
||||||
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
|
value: liveActivityFuel.displayName,
|
||||||
|
isEnabled: liveActivityEnabled) {
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
optionRow(String(localized: "Distance", comment: "Distance picker title"),
|
||||||
|
value: laDistanceValueLabel,
|
||||||
|
isEnabled: liveActivityEnabled) {
|
||||||
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
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)
|
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
|
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
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)
|
||||||
|
}
|
||||||
|
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
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"))
|
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ struct BestValueIntroContent: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
slider(
|
slider(
|
||||||
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
|
title: String(localized: "Fill-up amount", comment: "Best Value fill-up slider label"),
|
||||||
readOut: fillUpText,
|
readOut: fillUpText,
|
||||||
value: amountBinding,
|
value: amountBinding,
|
||||||
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
||||||
|
|||||||
+316
-133
@@ -29,15 +29,14 @@ struct ContentView: View {
|
|||||||
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
|
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
|
||||||
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
|
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
|
||||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
|
||||||
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||||
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
@State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
|
||||||
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
|
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
|
||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@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 alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
|
||||||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||||
@@ -114,6 +113,21 @@ struct ContentView: View {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// QA hook: `-priceStyle poundsPence` renders prices in the wide pounds &
|
||||||
|
/// pence form for ONE launch. Same reason as `-sortMode`: simctl has no tap
|
||||||
|
/// command, so the wide price has to be measured against the row's column
|
||||||
|
/// widths without walking Settings → Units for every check. Never persisted.
|
||||||
|
private static func qaPriceStyleOverride(default fallback: PriceDisplayStyle) -> PriceDisplayStyle {
|
||||||
|
#if DEBUG
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
if let i = args.firstIndex(of: "-priceStyle"), i + 1 < args.count,
|
||||||
|
let style = PriceDisplayStyle(rawValue: args[i + 1]) {
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
|
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let args = ProcessInfo.processInfo.arguments
|
let args = ProcessInfo.processInfo.arguments
|
||||||
@@ -137,31 +151,42 @@ struct ContentView: View {
|
|||||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveAlertsMode: AlertMode {
|
|
||||||
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureBestValueAlertModeIsAvailable() {
|
|
||||||
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
||||||
monitor.update(
|
monitor.update(
|
||||||
stations: stations,
|
stations: stations,
|
||||||
favourites: refreshedFavourites,
|
favourites: refreshedFavourites,
|
||||||
fuel: overrideFuel ?? alertsFuel,
|
fuel: overrideFuel ?? alertsFuel,
|
||||||
radiusKM: effectiveAlertsRadiusKM,
|
radiusKM: effectiveAlertsRadiusKM,
|
||||||
alertMode: effectiveAlertsMode,
|
alertMode: .cheapest,
|
||||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||||
fuelSavingsSettings: fuelSavingsSettings,
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
fuelSavingsAmount: sessionFuelSavingsAmount
|
fuelSavingsAmount: sessionFuelSavingsAmount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// QA hook: `-qaConfigureSavings` configures the Fuel Savings feature for the
|
||||||
|
/// session (the sheet's Best Value card, the Worth the Drive sort and the
|
||||||
|
/// Key sheet's Best Value row are ALL gated on `settings.isConfigured`), so
|
||||||
|
/// they can be screenshotted without walking Settings. It writes through the
|
||||||
|
/// store exactly as the Settings screen does and returns what it wrote, so
|
||||||
|
/// the app runs on the same stored values — not a stubbed, parallel one.
|
||||||
|
static func qaSavingsSettingsOverride(default value: FuelSavingsSettings) -> FuelSavingsSettings {
|
||||||
|
#if DEBUG
|
||||||
|
guard ProcessInfo.processInfo.arguments.contains("-qaConfigureSavings") else { return value }
|
||||||
|
let seeded = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: value.mpg,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
)
|
||||||
|
FuelStore.saveFuelSavingsSettings(seeded)
|
||||||
|
return seeded
|
||||||
|
#else
|
||||||
|
return value
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func installLocationUpdateHook() {
|
private func installLocationUpdateHook() {
|
||||||
locationManager.onLocationUpdate = { [weak monitor] in
|
locationManager.onLocationUpdate = {
|
||||||
self.updateAlertMonitor()
|
self.updateAlertMonitor()
|
||||||
// The Live Activity follows the same wake-ups — this hook
|
// The Live Activity follows the same wake-ups — this hook
|
||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
@@ -401,28 +426,27 @@ struct ContentView: View {
|
|||||||
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
||||||
/// all AGAIN to fill the per-station map. Both now share one result.
|
/// all AGAIN to fill the per-station map. Both now share one result.
|
||||||
///
|
///
|
||||||
/// The baseline is chosen with EXACTLY the loop and guards of
|
/// The baseline is chosen with the same journey guards as
|
||||||
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest
|
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
|
||||||
/// station" cannot move — and it is still selected when the feature is
|
/// off, the ordinary Closest station still names that row.
|
||||||
/// unconfigured (it names that row), while the maps are only built when the
|
|
||||||
/// labels will actually be shown.
|
|
||||||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||||||
guard let location else { return (nil, [:]) }
|
guard let location else { return (nil, [:]) }
|
||||||
let configured = fuelSavingsSettings.isConfigured
|
guard fuelSavingsSettings.isConfigured else {
|
||||||
|
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
|
||||||
|
savingsJourneyMiles(for: $0, location: location)
|
||||||
|
}
|
||||||
|
return (baseline, [:])
|
||||||
|
}
|
||||||
var journeys: [String: FuelSavingsJourney] = [:]
|
var journeys: [String: FuelSavingsJourney] = [:]
|
||||||
var bestStation: FuelStation?
|
|
||||||
var bestMiles = Double.infinity
|
|
||||||
for station in stations where station.prices[selectedFuel] != nil {
|
for station in stations where station.prices[selectedFuel] != nil {
|
||||||
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue }
|
if let journey = savingsJourneyMiles(for: station, location: location) {
|
||||||
if configured { journeys[station.id] = journey }
|
journeys[station.id] = journey
|
||||||
let miles = journey.miles
|
|
||||||
guard miles.isFinite, miles >= 0 else { continue }
|
|
||||||
if miles < bestMiles {
|
|
||||||
bestMiles = miles
|
|
||||||
bestStation = station
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (bestStation, configured ? journeys : [:])
|
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
|
||||||
|
journeys[$0.id]
|
||||||
|
}
|
||||||
|
return (baseline, journeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row/detail savings lookup with the comparison baseline and the per-station
|
/// Row/detail savings lookup with the comparison baseline and the per-station
|
||||||
@@ -566,10 +590,34 @@ struct ContentView: View {
|
|||||||
func source(_ basis: FuelSavingsDistanceBasis) -> String {
|
func source(_ basis: FuelSavingsDistanceBasis) -> String {
|
||||||
basis == .roadRoute ? "road route" : "straight-line estimate"
|
basis == .roadRoute ? "road route" : "straight-line estimate"
|
||||||
}
|
}
|
||||||
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
func amount(_ value: FuelSavingsFillAmount) -> String {
|
||||||
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
switch value.unit {
|
||||||
|
case .pounds: return String(format: "£%.2f", value.value)
|
||||||
|
case .litres: return String(format: "%.2f L", value.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func rawMoney(_ value: Double?) -> String {
|
||||||
|
value.map { String(format: "£%.6f", $0) } ?? "—"
|
||||||
|
}
|
||||||
|
let candidatePriceValue = station.prices[selectedFuel]
|
||||||
|
let baselinePriceValue = baseline?.prices[selectedFuel]
|
||||||
|
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||||
|
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
|
||||||
|
let priceDifference = candidatePriceValue.flatMap { candidate in
|
||||||
|
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
|
||||||
|
} ?? "—"
|
||||||
|
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
|
||||||
let rows: [(String, String)] = [
|
let rows: [(String, String)] = [
|
||||||
("Station", "\(station.name) (\(station.id))"),
|
("Station", "\(station.name) (\(station.id))"),
|
||||||
|
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
|
||||||
|
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
|
||||||
|
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
|
||||||
|
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
|
||||||
|
("Candidate price", price),
|
||||||
|
("Baseline price", baselinePrice),
|
||||||
|
("Price difference", priceDifference),
|
||||||
|
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
|
||||||
|
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
|
||||||
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
|
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
|
||||||
("Outward road", meters(stationDistance.outwardMeters)),
|
("Outward road", meters(stationDistance.outwardMeters)),
|
||||||
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
|
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
|
||||||
@@ -581,12 +629,11 @@ struct ContentView: View {
|
|||||||
("Station journey", miles(journey?.miles)),
|
("Station journey", miles(journey?.miles)),
|
||||||
("Baseline journey", miles(baselineJourney?.miles)),
|
("Baseline journey", miles(baselineJourney?.miles)),
|
||||||
("Additional journey", miles(result?.extraDistanceMiles)),
|
("Additional journey", miles(result?.extraDistanceMiles)),
|
||||||
|
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
|
||||||
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
|
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
|
||||||
("Fuel price", price),
|
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
|
||||||
("Baseline price", baselinePrice),
|
("Net saving raw", rawMoney(result?.netSavingGBP)),
|
||||||
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
|
("Displayed net saving", result?.headline() ?? "—")
|
||||||
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
|
|
||||||
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
|
|
||||||
]
|
]
|
||||||
return FuelSavingsJourneyDiagnostics(rows: rows)
|
return FuelSavingsJourneyDiagnostics(rows: rows)
|
||||||
#else
|
#else
|
||||||
@@ -777,6 +824,7 @@ struct ContentView: View {
|
|||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
|
priceStyle: priceDisplayStyle,
|
||||||
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
||||||
journeys: scaffold.journeys)(station),
|
journeys: scaffold.journeys)(station),
|
||||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||||
@@ -806,6 +854,15 @@ struct ContentView: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
|
||||||
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
|
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||||
|
// "Setup Best Value" in the How Best Value works sheet: close
|
||||||
|
// the More info sheet it was opened from (nil dismisses it —
|
||||||
|
// the sheet is item-driven) and switch to Settings. Tag 3 is
|
||||||
|
// the Settings tab; its Best Value section is the first item
|
||||||
|
// in the list, so the controls are on screen on arrival.
|
||||||
|
monitor.pendingDetailStation = nil
|
||||||
|
selectedTab = 3
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||||
let info = note.userInfo ?? [:]
|
let info = note.userInfo ?? [:]
|
||||||
if let id = info["stationID"] as? String,
|
if let id = info["stationID"] as? String,
|
||||||
@@ -1086,7 +1143,6 @@ struct ContentView: View {
|
|||||||
// or the choice wouldn't take effect until the next launch.
|
// or the choice wouldn't take effect until the next launch.
|
||||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
installLocationUpdateHook()
|
installLocationUpdateHook()
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
@@ -1096,14 +1152,25 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: fuelSavingsSettings) { old, new in
|
.onChange(of: fuelSavingsSettings) { old, new in
|
||||||
|
// An explicit Settings default edit supersedes the temporary slider
|
||||||
|
// override. Changes to MPG/enabled alone leave the session fill intact.
|
||||||
|
if old.preferredAmount != new.preferredAmount {
|
||||||
|
sessionFuelSavingsAmount = new.preferredAmount
|
||||||
|
}
|
||||||
// Fuel Savings changed in Settings while the app was running: the
|
// Fuel Savings changed in Settings while the app was running: the
|
||||||
// effective sort follows automatically (no stored sort is touched).
|
// effective sort follows automatically (no stored sort is touched).
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||||
#endif
|
#endif
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
}
|
}
|
||||||
|
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||||
|
// This remains a session-only override: do not write it to FuelStore.
|
||||||
|
// Any active Best Value alert/Live Activity must nevertheless use
|
||||||
|
// the same effective amount as rows, details, favourites and sorting.
|
||||||
|
updateAlertMonitor()
|
||||||
|
updateLiveActivity()
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
// Never start location tracking while onboarding is on screen —
|
// Never start location tracking while onboarding is on screen —
|
||||||
@@ -1177,16 +1244,6 @@ struct ContentView: View {
|
|||||||
FuelStore.saveAlertsFuel(newValue)
|
FuelStore.saveAlertsFuel(newValue)
|
||||||
updateAlertMonitor(fuel: newValue)
|
updateAlertMonitor(fuel: newValue)
|
||||||
}
|
}
|
||||||
.onChange(of: alertsMode) { _, newValue in
|
|
||||||
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
updateAlertMonitor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
FuelStore.saveAlertsMode(newValue)
|
|
||||||
updateAlertMonitor()
|
|
||||||
}
|
|
||||||
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
||||||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
@@ -1493,7 +1550,6 @@ struct ContentView: View {
|
|||||||
fuelSavingsSettings: fuelSavingsSettings,
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||||
savingsBaselineStationName: baselineStation?.name,
|
|
||||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
@@ -1515,7 +1571,6 @@ struct ContentView: View {
|
|||||||
fuelSavingsSettings: fuelSavingsSettings,
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
fuelSavingsAmount: $sessionFuelSavingsAmount,
|
||||||
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
|
||||||
savingsBaselineStationName: baselineStation?.name,
|
|
||||||
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
@@ -1531,9 +1586,6 @@ struct ContentView: View {
|
|||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
fuel: $alertsFuel,
|
fuel: $alertsFuel,
|
||||||
alertMode: $alertsMode,
|
|
||||||
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
|
|
||||||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
|
||||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||||
followsSearch: $alertsFollowsSearch,
|
followsSearch: $alertsFollowsSearch,
|
||||||
@@ -1560,6 +1612,7 @@ struct ContentView: View {
|
|||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
stationTapAction: $stationTapAction,
|
stationTapAction: $stationTapAction,
|
||||||
fuelSavingsSettings: $fuelSavingsSettings,
|
fuelSavingsSettings: $fuelSavingsSettings,
|
||||||
|
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
|
||||||
dataRefreshMode: $dataRefreshMode,
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
@@ -1743,20 +1796,42 @@ struct StationRow: View {
|
|||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
|
/// This row is the cheapest price in range — the caller's existing
|
||||||
|
/// `topStationID` test. Feeds the CHEAPEST badge.
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
/// Label for the cheapest-pump-price badge ("TOP" in price/distance order,
|
|
||||||
/// "CHEAPEST" when the list is ranked by net saving).
|
|
||||||
var topBadgeText: String = "TOP"
|
|
||||||
var tapAction: StationTapAction = .openMap
|
var tapAction: StationTapAction = .openMap
|
||||||
var fuelSavings: FuelSavingsAvailability?
|
var fuelSavings: FuelSavingsAvailability?
|
||||||
/// The session fill-up figure (the one the adjustment control edits). The
|
/// The user's Best Value alert threshold — the app's existing gate for
|
||||||
/// compact banner does not print it today, but a row that carried a hard
|
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
|
||||||
/// zero would print "for £0" the moment it did — pass the real figure.
|
/// exactly the rule the alerts already use. Read per row (the alert path
|
||||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
/// reads it per evaluation too); pass explicitly to inject a value.
|
||||||
|
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
var onShowDetails: (FuelStation) -> Void = { _ in }
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
|
/// The price's point size, scaled with Dynamic Type: the price is built from
|
||||||
|
/// an explicit size (its "p"/"/L" suffixes are sized relative to it inside
|
||||||
|
/// `priceTextAttributed`), so it needs a metric rather than a font style.
|
||||||
|
/// 22pt keeps the price the row's loudest element after the station name.
|
||||||
|
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
|
||||||
|
|
||||||
|
/// The identity column's share of the ROW, for every row and both price
|
||||||
|
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
|
||||||
|
/// held per row rather than derived from the row's text — the point is that
|
||||||
|
/// the hairline, the price axis and the star cannot move as the list scrolls.
|
||||||
|
/// The price column takes the remainder, and its listing verdict wraps
|
||||||
|
/// vertically instead of widening the column and dragging the hairline.
|
||||||
|
///
|
||||||
|
/// One value for both price styles: the star used to sit at the end of the
|
||||||
|
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
|
||||||
|
/// — ~20pt wider than the bare `158.7` at the same point size — did not fit on
|
||||||
|
/// a 393pt screen. With the star now under the station logo the price column
|
||||||
|
/// has that width back, so the identity column no longer has to give width up
|
||||||
|
/// in pounds & pence, and names keep the same room in both styles.
|
||||||
|
private static let leftSectionShare: CGFloat = 0.52
|
||||||
|
|
||||||
|
|
||||||
private var ragColor: Color {
|
private var ragColor: Color {
|
||||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||||
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
|
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
|
||||||
@@ -1782,6 +1857,26 @@ struct StationRow: View {
|
|||||||
return abs(price - baselinePrice) <= 0.05
|
return abs(price - baselinePrice) <= 0.05
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The row's single recommendation badge, if any. BEST VALUE (worth the
|
||||||
|
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
|
||||||
|
/// being context rather than a recommendation.
|
||||||
|
private var recommendationBadge: StationRecommendationBadge? {
|
||||||
|
StationRecommendationBadge.badge(
|
||||||
|
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
||||||
|
isCheapest: isTopResult,
|
||||||
|
bestValueThresholdGBP: bestValueThresholdGBP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver: the price, then the delta the redesign no longer prints — the
|
||||||
|
/// listing shows a savings capsule in its place, so the comparison against
|
||||||
|
/// the cheapest price in range would otherwise be visible only as a colour.
|
||||||
|
private func priceSpokenLabel(_ price: Double) -> String {
|
||||||
|
guard let deltaText else { return FuelStore.priceTextSpoken(price) }
|
||||||
|
let comparison = String(localized: "vs cheapest", comment: "Price delta comparison caption")
|
||||||
|
return "\(FuelStore.priceTextSpoken(price)), \(isCheapestPrice ? deltaText : "\(deltaText) \(comparison)")"
|
||||||
|
}
|
||||||
|
|
||||||
private func handleTap() {
|
private func handleTap() {
|
||||||
switch tapAction {
|
switch tapAction {
|
||||||
case .openMap:
|
case .openMap:
|
||||||
@@ -1798,9 +1893,31 @@ struct StationRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
// Two sections, one hairline: station identity on the left, price and
|
||||||
HStack(spacing: 12) {
|
// savings on the right. No wrapper VStack and no full-width verdict
|
||||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
// banner under the row — the savings capsule carries the verdict inside
|
||||||
|
// the right-hand column, and the extra travel behind it is explained in
|
||||||
|
// the detail sheet instead of being repeated on every row.
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
// LEFT SECTION — station identity: brand logo, then name, address and
|
||||||
|
// distance. Given a FIXED SHARE of the row (~52%, the mockup's ratio)
|
||||||
|
// rather than sizing to its content: sized to content, this column
|
||||||
|
// grew and shrank with each row's badge and savings copy, which moved
|
||||||
|
// the hairline 23pt between adjacent rows (x767 on a BEST VALUE row,
|
||||||
|
// x699 on a "costs more" row) — so the divider and the price axis
|
||||||
|
// both jumped sideways as the list scrolled. One share for every row
|
||||||
|
// puts them on a single vertical line down the whole list.
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
// The logo, with the favourite star directly beneath it. Moving
|
||||||
|
// the star out of the price line is what gives the price column
|
||||||
|
// its width back: sat at the end of that line it held ~28pt of
|
||||||
|
// the column, which is exactly the space the wide pounds &
|
||||||
|
// pence price needs for its own digits on a 393pt screen.
|
||||||
|
// 10pt between logo and star: at 4pt the star's glyph sat almost
|
||||||
|
// against the logo's bottom edge and read as part of it rather
|
||||||
|
// than as the control it is.
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
// Round brand logo (or generic fuel pump fallback)
|
||||||
Group {
|
Group {
|
||||||
if let asset = station.brandImageName {
|
if let asset = station.brandImageName {
|
||||||
Image(asset)
|
Image(asset)
|
||||||
@@ -1819,13 +1936,25 @@ struct StationRow: View {
|
|||||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||||
|
|
||||||
// MIDDLE — name, full address, distance
|
favouriteButton
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name, full address, distance
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
// The name wraps, capped at THREE lines. The row is tall
|
||||||
|
// enough for a two-line name, but an unbounded wrap let one
|
||||||
|
// very long forecourt name set the height of the whole row —
|
||||||
|
// the excessive row heights this refinement removes. Past
|
||||||
|
// three lines the name ellipsises; the full name is always
|
||||||
|
// in Station Details. Baseline alignment keeps the notice
|
||||||
|
// symbols on the name's FIRST line instead of floating
|
||||||
|
// beside a two-line name.
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(station.name)
|
Text(station.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(3)
|
||||||
.truncationMode(.tail)
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
ForEach(StationNotice.notices(for: station)) { notice in
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
Image(systemName: notice.symbolName)
|
Image(systemName: notice.symbolName)
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
@@ -1833,97 +1962,151 @@ struct StationRow: View {
|
|||||||
.accessibilityLabel(notice.title)
|
.accessibilityLabel(notice.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Wraps for the same reason as the name — addresses here run
|
||||||
|
// to 70 characters at the 95th percentile.
|
||||||
Text(StationAddressLine.display(address: station.address,
|
Text(StationAddressLine.display(address: station.address,
|
||||||
postcode: station.postcode,
|
postcode: station.postcode,
|
||||||
name: station.name))
|
name: station.name))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.truncationMode(.tail)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if let location {
|
if let location {
|
||||||
let distance = FuelStore.stationDistanceDisplay(
|
let distance = FuelStore.stationDistanceDisplay(
|
||||||
station: station, userLat: location.lat, userLng: location.lng
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
)
|
)
|
||||||
Text(distance.labelWithReturn(unit: distanceUnit))
|
// ONE-WAY distance: the listing shows how far the station
|
||||||
|
// is. The return leg stays in the savings explanation, where
|
||||||
|
// the extra travel it drives is actually costed.
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "location.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(distance.label(unit: distanceUnit))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.accessibilityLabel(distance.spokenLabelWithReturn(unit: distanceUnit))
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// RIGHT — price in £ (no p) + difference vs cheapest
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
if isTopResult {
|
|
||||||
// The badge always marks the CHEAPEST PUMP PRICE (never
|
|
||||||
// "best saving"), so in Best Value order — where the
|
|
||||||
// top row is the best net saving — it may sit lower down
|
|
||||||
// the list, and says so. It sits ABOVE the price it
|
|
||||||
// annotates rather than beside the name, so it reads as
|
|
||||||
// part of the price and the name keeps its width.
|
|
||||||
Text(topBadgeText)
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.padding(.horizontal, 5)
|
|
||||||
.padding(.vertical, 1)
|
|
||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize()
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
|
||||||
|
length * Self.leftSectionShare
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1pt hairline between the two sections. Inset top and bottom so it
|
||||||
|
// spans the row's content without touching the row edges, and
|
||||||
|
// decorative: the columns are told apart by their content, and two
|
||||||
|
// hairs per row would just be noise to VoiceOver.
|
||||||
|
//
|
||||||
|
// `secondary`, not `separator`: over a dark row fill the system
|
||||||
|
// separator measures 7/255 brighter than the fill — measurably
|
||||||
|
// invisible — while the mockup's rule sits ~112 above it. Secondary
|
||||||
|
// lands on the mockup's own value in both appearances.
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.secondary)
|
||||||
|
.frame(width: 1)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
// RIGHT SECTION — price and savings, every element leading-aligned on
|
||||||
|
// ONE axis. The 12pt row spacing plus this column's leading padding
|
||||||
|
// puts that axis ~22pt right of the hairline. (The mockup centres the
|
||||||
|
// price against the badge so its status dot hangs into the gutter; the
|
||||||
|
// redesign was explicitly asked not to do that.)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if let badge = recommendationBadge {
|
||||||
|
badgeCapsule(badge)
|
||||||
|
}
|
||||||
|
// The price and its verdict capsule are ONE vertically grouped
|
||||||
|
// element: 5pt binds them as "this price, this outcome", while
|
||||||
|
// the badge keeps the column's full 12pt above so the three
|
||||||
|
// lines do not read as equals.
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
// Price line: the status dot and the price, leading-aligned
|
||||||
|
// on the column's one axis. The star no longer ends this
|
||||||
|
// line — it sits under the station logo — so this whole
|
||||||
|
// column width now belongs to the price, which is what lets
|
||||||
|
// the wide pounds & pence form (£1.58⁷/L) render at full
|
||||||
|
// size rather than being scaled down or truncated.
|
||||||
if let price = station.prices[fuel] {
|
if let price = station.prices[fuel] {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Circle()
|
PriceRatingDot(color: ragColor)
|
||||||
.fill(ragColor)
|
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
||||||
.frame(width: 8, height: 8)
|
size: priceSize, weight: .bold)
|
||||||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
|
|
||||||
}
|
|
||||||
if let deltaText {
|
|
||||||
Text(deltaText)
|
|
||||||
.font(.caption2.bold().monospaced())
|
|
||||||
.foregroundStyle(ragColor)
|
|
||||||
if !isCheapestPrice {
|
|
||||||
// "+8.0p" on its own never said what it measured
|
|
||||||
// against; the delta is against the cheapest price
|
|
||||||
// in range in EVERY sort mode.
|
|
||||||
Text("vs cheapest")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.8)
|
||||||
}
|
}
|
||||||
|
// The visible "+1.8p vs cheapest" line is gone with the
|
||||||
|
// redesign, so the comparison rides on the price's label.
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(priceSpokenLabel(price))
|
||||||
|
}
|
||||||
|
if let result = fuelSavings?.result {
|
||||||
|
savingsCapsule(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
// FAR RIGHT — favourite star
|
// The spec's ~20-24pt of air between the divider and the price
|
||||||
Button {
|
// column: the row's own 12pt spacing plus this 10pt.
|
||||||
onToggleFavourite()
|
.padding(.leading, 10)
|
||||||
} label: {
|
|
||||||
Image(systemName: isFavourite ? "star.fill" : "star")
|
|
||||||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
if fuelSavings != nil {
|
|
||||||
// Full-width verdict banner under the row. Inline in the middle
|
|
||||||
// column it truncated ("Approx. £2.75 better off after tra…")
|
|
||||||
// because the
|
|
||||||
// price block squeezes that column — and the verdict is the
|
|
||||||
// point of this sort mode, so it gets its own line.
|
|
||||||
FuelSavingsSummaryView(
|
|
||||||
availability: fuelSavings,
|
|
||||||
amount: fuelSavingsAmount,
|
|
||||||
baselineStationName: nil,
|
|
||||||
compact: true,
|
|
||||||
distanceUnit: distanceUnit
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(perform: handleTap)
|
.onTapGesture(perform: handleTap)
|
||||||
.onLongPressGesture(perform: showDetails)
|
.onLongPressGesture(perform: showDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The favourite toggle, sitting under the station logo. Kept as its own view
|
||||||
|
/// so it stays a separate VoiceOver element — the price beside it is a single
|
||||||
|
/// grouped label — and keeps its own tap target inside the row's tap and
|
||||||
|
/// long-press gestures.
|
||||||
|
private var favouriteButton: some View {
|
||||||
|
Button {
|
||||||
|
onToggleFavourite()
|
||||||
|
} label: {
|
||||||
|
FavouriteStarGlyph(isFavourite: isFavourite)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing's verdict capsule — ONE component for every status, so the
|
||||||
|
/// baseline's "Closest station" pill carries the same soft blue fill, white
|
||||||
|
/// text and shape rules as Saves, Costs and Break even. The detail sheet
|
||||||
|
/// keeps `FuelSavingsCapsule` to itself, unchanged.
|
||||||
|
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
||||||
|
ListingFuelSavingsCapsule(result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recommendation badge, above the price it annotates. Badges stay
|
||||||
|
/// TINTED with coloured text — unlike the verdict capsules, which are filled
|
||||||
|
/// with white text: a badge annotates the price rather than reporting an
|
||||||
|
/// outcome, so it should stay quieter than the pill beneath it. Tinted text
|
||||||
|
/// also keeps its contrast in both appearances at every Dynamic Type size
|
||||||
|
/// without introducing a colour outside the palette.
|
||||||
|
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||||||
|
StationLabelBadge(label: badge.title, color: badge.tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
||||||
|
/// they stay out of the shared model the tests cover.
|
||||||
|
private extension StationRecommendationBadge {
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .bestValue:
|
||||||
|
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
|
||||||
|
case .cheapest:
|
||||||
|
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color { self == .bestValue ? .green : .blue }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Location manager
|
// MARK: - Location manager
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ struct FavouritesView: View {
|
|||||||
/// still never touches the Settings default.
|
/// still never touches the Settings default.
|
||||||
let fuelSavingsSettings: FuelSavingsSettings
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
/// Estimated saving for a favourite, against the nearest eligible station —
|
/// Estimated saving for a favourite, against the closest station by
|
||||||
/// the same baseline and definition the Stations list uses.
|
/// journey distance — the same baseline and definition the Stations list uses.
|
||||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
var savingsBaselineStationName: String?
|
|
||||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
@@ -49,6 +48,14 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// The Key (legend) sheet — the same sheet the Stations tab presents, opened
|
||||||
|
/// from the info button beside Edit.
|
||||||
|
@State private var showKey = false
|
||||||
|
/// The Key sheet's two nested sheets, owned here because its rows set them
|
||||||
|
/// (same arrangement as StationsView, so the QA hooks behave identically).
|
||||||
|
@State private var showBestValueInfo = false
|
||||||
|
@State private var showImportantInformation = false
|
||||||
|
|
||||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
@State private var detailStation: FuelStation?
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
@@ -70,6 +77,15 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
||||||
|
|
||||||
|
/// The baseline note is useful only when at least one visible favourite has
|
||||||
|
/// an actual savings comparison. An unavailable result (for example, while
|
||||||
|
/// location is missing) must not leave an orphaned explanation below the list.
|
||||||
|
private var showsSavingsComparisonExplanation: Bool {
|
||||||
|
fuelSavingsSettings.isConfigured && ordered.contains { station in
|
||||||
|
savingsForStation(station)?.result != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// IDs favourited for the active fuel — the star state on each row.
|
/// IDs favourited for the active fuel — the star state on each row.
|
||||||
private var activeFuelFavouriteIDs: Set<String> {
|
private var activeFuelFavouriteIDs: Set<String> {
|
||||||
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
||||||
@@ -84,7 +100,6 @@ struct FavouritesView: View {
|
|||||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||||
savingsBaselineStationName: String? = nil,
|
|
||||||
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
@@ -99,7 +114,6 @@ struct FavouritesView: View {
|
|||||||
self.fuelSavingsSettings = fuelSavingsSettings
|
self.fuelSavingsSettings = fuelSavingsSettings
|
||||||
_fuelSavingsAmount = fuelSavingsAmount
|
_fuelSavingsAmount = fuelSavingsAmount
|
||||||
self.savingsForStation = savingsForStation
|
self.savingsForStation = savingsForStation
|
||||||
self.savingsBaselineStationName = savingsBaselineStationName
|
|
||||||
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
@@ -147,12 +161,16 @@ struct FavouritesView: View {
|
|||||||
|
|
||||||
if fuelSavingsSettings.isConfigured {
|
if fuelSavingsSettings.isConfigured {
|
||||||
Section {
|
Section {
|
||||||
FuelSavingsAdjustmentView(
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
amount: $fuelSavingsAmount,
|
}
|
||||||
referencePricePencePerLitre: cheapestPrice
|
}
|
||||||
)
|
|
||||||
} footer: {
|
if showsSavingsComparisonExplanation {
|
||||||
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
Section {
|
||||||
|
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +185,8 @@ struct FavouritesView: View {
|
|||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: station.id == cheapest?.id,
|
isTopResult: station.id == cheapest?.id,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
topBadgeText: "CHEAPEST",
|
|
||||||
tapAction: stationTapAction,
|
tapAction: stationTapAction,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
onShowDetails: { detailStation = $0 }
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
@@ -179,8 +195,13 @@ struct FavouritesView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||||
} footer: {
|
} footer: {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
||||||
}
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
if let cheapest = cheapest {
|
if let cheapest = cheapest {
|
||||||
Section {
|
Section {
|
||||||
@@ -193,8 +214,8 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !favourites.isEmpty {
|
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
if !favourites.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
showTrends = true
|
showTrends = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -203,7 +224,24 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
EditButton()
|
EditButton()
|
||||||
}
|
}
|
||||||
|
// Same button and same slot as the Stations tab: last in the
|
||||||
|
// group, so it sits to the right of Edit. Shown even with no
|
||||||
|
// favourites — the legend explains the prices and labels, which
|
||||||
|
// matter before anything is starred.
|
||||||
|
Button {
|
||||||
|
showKey = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Key", comment: "Key button accessibility label"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showKey) {
|
||||||
|
KeySheetView(
|
||||||
|
fuelTint: activeFuel.tintColor,
|
||||||
|
showBestValueInfo: $showBestValueInfo,
|
||||||
|
showImportantInformation: $showImportantInformation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showTrends) {
|
.sheet(isPresented: $showTrends) {
|
||||||
TrendsView(
|
TrendsView(
|
||||||
@@ -219,9 +257,10 @@ struct FavouritesView: View {
|
|||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
|
priceStyle: priceDisplayStyle,
|
||||||
|
selectedFuel: activeFuel,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
savingsBaselineStationName: savingsBaselineStationName,
|
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
// Every station listed here IS a favourite for the tab's
|
// Every station listed here IS a favourite for the tab's
|
||||||
// fuel, so the star starts filled; tapping it unstars.
|
// fuel, so the star starts filled; tapping it unstars.
|
||||||
@@ -229,6 +268,12 @@ struct FavouritesView: View {
|
|||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||||
|
// "Setup Best Value" from the explanation sheet, opened inside
|
||||||
|
// this view's More info sheet. That sheet closes itself; this one
|
||||||
|
// is presented from HERE and would otherwise stay over Settings.
|
||||||
|
detailStation = nil
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Applies FuelBoard's shared unavailable appearance and native interaction state
|
||||||
|
/// to a complete dependent row or group. Apply it once at the highest dependent
|
||||||
|
/// container so labels, values, icons, descriptions and controls fade together.
|
||||||
|
extension View {
|
||||||
|
func fuelBoardDisabledState(isEnabled: Bool) -> some View {
|
||||||
|
disabled(!isEnabled)
|
||||||
|
.allowsHitTesting(isEnabled)
|
||||||
|
.opacity(isEnabled ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
-136
@@ -2,7 +2,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FuelSavingsAdjustmentView: View {
|
struct FuelSavingsAdjustmentView: View {
|
||||||
@Binding var amount: FuelSavingsFillAmount
|
@Binding var amount: FuelSavingsFillAmount
|
||||||
let referencePricePencePerLitre: Double?
|
|
||||||
|
@State private var showingComparisonHelp = false
|
||||||
|
|
||||||
private var range: ClosedRange<Double> {
|
private var range: ClosedRange<Double> {
|
||||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||||
@@ -25,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("How much are you filling up?")
|
HStack {
|
||||||
|
Text("How much fuel?")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
showingComparisonHelp = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("About comparison modes")
|
||||||
|
.popover(isPresented: $showingComparisonHelp) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Comparison modes")
|
||||||
|
.font(.headline)
|
||||||
|
Text("£: Compare how much fuel your budget buys at each station.")
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text("Litres: Compare the cost of buying the same amount of fuel.")
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.frame(width: 280, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.presentationCompactAdaptation(.popover)
|
||||||
|
}
|
||||||
|
}
|
||||||
Picker("Fill-up unit", selection: unitBinding) {
|
Picker("Fill-up unit", selection: unitBinding) {
|
||||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
Text(unit.displayName).tag(unit)
|
Text(unit.displayName).tag(unit)
|
||||||
@@ -41,7 +67,7 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.frame(width: 72, alignment: .trailing)
|
.frame(width: 72, alignment: .trailing)
|
||||||
}
|
}
|
||||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
Text("Adjust your fill-up amount for this visit. Your default won’t change.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -52,12 +78,9 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
get: { amount.unit },
|
get: { amount.unit },
|
||||||
set: { newUnit in
|
set: { newUnit in
|
||||||
guard newUnit != amount.unit else { return }
|
guard newUnit != amount.unit else { return }
|
||||||
if let referencePricePencePerLitre,
|
// No one litre figure equals a budget at every candidate price,
|
||||||
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
// so switching modes starts from that mode's configured default.
|
||||||
amount = converted
|
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||||
} else {
|
|
||||||
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -70,145 +93,42 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelSavingsSummaryView: View {
|
/// The colour-coded monetary verdict capsule used by the detail sheet and other
|
||||||
let availability: FuelSavingsAvailability?
|
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
|
||||||
let amount: FuelSavingsFillAmount
|
///
|
||||||
let baselineStationName: String?
|
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
||||||
var compact: Bool = false
|
/// a Label lays its icon and title out across the row and leaves a visible gap
|
||||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
/// between them (22pt in the listing row), which read as the text having
|
||||||
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
/// drifted away from its symbol. The icon is decorative here — the verdict is
|
||||||
var distanceUnit: DistanceUnit = .miles
|
/// in the text — so VoiceOver reads the capsule as one phrase.
|
||||||
|
struct FuelSavingsCapsule: View {
|
||||||
|
let result: FuelSavingsResult
|
||||||
|
var compact: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch availability {
|
|
||||||
case .available(let result):
|
|
||||||
if compact {
|
|
||||||
// FULL-WIDTH banner under the row. Inline inside the middle
|
|
||||||
// column the capsule truncated ("Approx. £2.75 better off after
|
|
||||||
// tra…") because
|
|
||||||
// the column is squeezed by the price block — the verdict is the
|
|
||||||
// point of this sort mode, so it gets its own line with room.
|
|
||||||
// Colour carries the verdict: green save, red worse off, grey
|
|
||||||
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
|
||||||
// it to one line, so rows stay even.
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
capsule(for: result, compact: true)
|
|
||||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
|
||||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
||||||
extraDistanceMiles: result.extraDistanceMiles,
|
|
||||||
unit: distanceUnit,
|
|
||||||
confidence: result.journeyConfidence
|
|
||||||
) {
|
|
||||||
Text(extraTravel)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.85)
|
|
||||||
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Aligns the banner with the row's text column (46 pt logo + 12
|
|
||||||
// pt spacing) so it reads as part of the row, not the cell.
|
|
||||||
.padding(.leading, 58)
|
|
||||||
} else {
|
|
||||||
// Detail sheet: the SAME verdict capsule and the SAME caption as
|
|
||||||
// the listing rows (one implementation, so wording and colour
|
|
||||||
// cannot drift), followed by the breakdown only this view has
|
|
||||||
// room for. The old shape led with a status word + headline that
|
|
||||||
// matched neither the listing nor each other.
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
capsule(for: result, compact: false)
|
|
||||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
|
||||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
|
||||||
extraDistanceMiles: result.extraDistanceMiles,
|
|
||||||
unit: distanceUnit,
|
|
||||||
confidence: result.journeyConfidence
|
|
||||||
) {
|
|
||||||
Text(extraTravel)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
|
||||||
}
|
|
||||||
if result.status == .baseline {
|
|
||||||
Text("Your closest station with a price for this fuel — every other saving is measured against it.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
|
||||||
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
|
||||||
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
|
||||||
if result.journeyConfidence.isEstimated {
|
|
||||||
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .unavailable(let reason):
|
|
||||||
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
|
|
||||||
.font(compact ? .caption2 : .caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
case nil:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var amountText: String {
|
|
||||||
switch amount.unit {
|
|
||||||
case .pounds: return String(format: "£%.0f", amount.value)
|
|
||||||
case .litres: return String(format: "%.0f litres", amount.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
|
|
||||||
/// return travel"): the abbreviation is spelled out and the unit is a word.
|
|
||||||
/// Falls back to the visible string if no spoken form exists.
|
|
||||||
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
|
|
||||||
FuelSavingsFormat.extraTravelSpokenLabel(
|
|
||||||
extraDistanceMiles: result.extraDistanceMiles,
|
|
||||||
unit: distanceUnit,
|
|
||||||
confidence: result.journeyConfidence
|
|
||||||
) ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
|
|
||||||
/// estimated comparisons and leaves the plain words ("Break even", "Closest
|
|
||||||
/// station") alone — one source, so visible and spoken copy cannot drift.
|
|
||||||
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
|
|
||||||
result.spokenHeadline()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The colour-coded verdict capsule — ONE implementation for the listing rows
|
|
||||||
/// and the detail sheet, so the verdict reads identically in both; the sheet
|
|
||||||
/// just gets the larger font and padding.
|
|
||||||
///
|
|
||||||
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
|
||||||
/// a Label lays its icon and title out across the row and leaves a visible
|
|
||||||
/// gap between them (22pt in the listing row), which read as the text having
|
|
||||||
/// drifted away from its symbol. The icon is decorative here — the verdict
|
|
||||||
/// is in the text — so VoiceOver reads the capsule as one phrase.
|
|
||||||
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
|
|
||||||
HStack(spacing: compact ? 3 : 5) {
|
HStack(spacing: compact ? 3 : 5) {
|
||||||
Image(systemName: symbol(for: result.status))
|
Image(systemName: Self.symbol(for: result.status))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text(result.capsuleHeadline())
|
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||||||
|
// which the icon, its spacing and the 8pt padding either side
|
||||||
|
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
||||||
|
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
||||||
|
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
||||||
|
// which clears it at ~10pt type: scaled, never truncated.
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
}
|
}
|
||||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||||
.foregroundStyle(capsuleColor(for: result.status))
|
.foregroundStyle(Self.color(for: result.status))
|
||||||
.padding(.horizontal, compact ? 8 : 12)
|
.padding(.horizontal, compact ? 8 : 12)
|
||||||
.padding(.vertical, compact ? 4 : 7)
|
.padding(.vertical, compact ? 4 : 7)
|
||||||
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
|
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
/// Verdict colour: green save, red worse off, grey break even, blue
|
||||||
/// baseline — the same green/amber/red family the price dot already uses.
|
/// baseline — the same green/amber/red family the price dot already uses.
|
||||||
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
|
static func color(for status: FuelSavingsStatus) -> Color {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return .green
|
case .worthTheDrive: return .green
|
||||||
case .notWorthTheDrive: return .red
|
case .notWorthTheDrive: return .red
|
||||||
@@ -217,7 +137,7 @@ struct FuelSavingsSummaryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func symbol(for status: FuelSavingsStatus) -> String {
|
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return "checkmark.circle.fill"
|
case .worthTheDrive: return "checkmark.circle.fill"
|
||||||
case .breakEven: return "equal.circle.fill"
|
case .breakEven: return "equal.circle.fill"
|
||||||
@@ -226,3 +146,156 @@ struct FuelSavingsSummaryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The station listing's verdict capsule: the words on the first line, the figure
|
||||||
|
/// on its own line beneath, and "(approx.)" on a third when the journey is an
|
||||||
|
/// estimate.
|
||||||
|
///
|
||||||
|
/// Stations and Favourites share `StationRow`, so both render this. The detail
|
||||||
|
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
|
||||||
|
/// the Key sheet's legend renders this with `showsAmount: false` so it never
|
||||||
|
/// displays an invented figure.
|
||||||
|
struct ListingFuelSavingsCapsule: View {
|
||||||
|
let result: FuelSavingsResult
|
||||||
|
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
|
||||||
|
var showsAmount: Bool = true
|
||||||
|
|
||||||
|
/// The height the text actually drew, so the shape can follow the drawn line
|
||||||
|
/// count rather than the data's. See `drawnLineCount`.
|
||||||
|
@State private var textHeight: CGFloat = 0
|
||||||
|
/// One caption line's height, scaled with Dynamic Type, to divide that by.
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
|
||||||
|
|
||||||
|
private var verdict: FuelSavingsResult.ListingVerdict {
|
||||||
|
result.listingVerdict(showsAmount: showsAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
// One Text per line, not a "\n" inside one string: each line keeps its
|
||||||
|
// own localisation key and the amount can be monospaced on its own.
|
||||||
|
// Left-aligned: the word and its figure share a left edge, so the
|
||||||
|
// block reads as a column rather than a ragged centre.
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(verdict.headline)
|
||||||
|
if let amount = verdict.amount {
|
||||||
|
Text(amount)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
if let qualifier = verdict.qualifier {
|
||||||
|
Text(qualifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
// Never compress or truncate: the capsule wraps and the row grows taller.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: CapsuleTextHeightKey.self,
|
||||||
|
value: proxy.size.height)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// White on the muted fill. These capsules carry a figure the eye is asked
|
||||||
|
// to read at a glance, which is why they are the only filled-and-white
|
||||||
|
// surfaces in the app; every badge stays tinted with coloured text.
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
// Saves and Costs share one minimum width, so their trailing edges line
|
||||||
|
// up down the list whatever the figure's length. Break even and Closest
|
||||||
|
// station carry no figure and keep their natural width.
|
||||||
|
.frame(minWidth: minimumWidth, alignment: .leading)
|
||||||
|
.background(shape.fill(fill))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(verdict.spokenText)
|
||||||
|
.onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves and Costs only — the two verdicts that carry a figure. See the
|
||||||
|
/// frame above: 84pt is the width the widest common amount needs
|
||||||
|
/// ("£123.45 more"), so shorter figures pad rather than shrink the pill.
|
||||||
|
private var minimumWidth: CGFloat {
|
||||||
|
switch result.status {
|
||||||
|
case .worthTheDrive, .notWorthTheDrive: return 84
|
||||||
|
case .breakEven, .baseline: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fill: Color {
|
||||||
|
ListingCapsuleFill.color(for: result.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many lines the text DREW, not how many the verdict declares.
|
||||||
|
/// "Closest station" is one declared line that wraps to two in the row's
|
||||||
|
/// narrow column, and the shape has to follow what is on screen or the pill's
|
||||||
|
/// semicircular ends crowd the text they are wrapping. Falls back to the
|
||||||
|
/// declared count on the first layout pass, before there is a height.
|
||||||
|
private var drawnLineCount: Int {
|
||||||
|
guard textHeight > 0, captionLineHeight > 0 else { return verdict.lines.count }
|
||||||
|
return max(1, Int((textHeight / captionLineHeight).rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
|
||||||
|
/// semicircular ends crowd the text corners at that height, while an 8pt
|
||||||
|
/// radius does not. Keyed to the DRAWN line count, so a wrapped "Closest
|
||||||
|
/// station" gets the same rounded rect as a figure pushed onto its own line.
|
||||||
|
private var shape: AnyShape {
|
||||||
|
drawnLineCount > 1
|
||||||
|
? AnyShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
: AnyShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbol: String {
|
||||||
|
switch result.status {
|
||||||
|
case .worthTheDrive: return "checkmark.circle"
|
||||||
|
case .notWorthTheDrive: return "arrow.up.circle"
|
||||||
|
case .breakEven: return "equal.circle"
|
||||||
|
case .baseline: return "location.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carries the drawn height of a capsule's text block up to `drawnLineCount`.
|
||||||
|
private struct CapsuleTextHeightKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = max(value, nextValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The station listing's verdict-capsule fills: muted semantic colours carrying
|
||||||
|
/// white text and icon.
|
||||||
|
///
|
||||||
|
/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the
|
||||||
|
/// app: white text on a 15% tint over a white row measures ~1.8:1 and is
|
||||||
|
/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band
|
||||||
|
/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a
|
||||||
|
/// white row and the dark card fill — so one value each works in both
|
||||||
|
/// appearances without a dynamic colour.
|
||||||
|
///
|
||||||
|
/// The saturated palette colours cannot carry white text at all: measured
|
||||||
|
/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for
|
||||||
|
/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is
|
||||||
|
/// therefore a contrast requirement here, not only the mockup's taste.
|
||||||
|
enum ListingCapsuleFill {
|
||||||
|
// Trailing comments record, per colour: white-on-fill / fill-vs-white-row /
|
||||||
|
// fill-vs-dark-row, measured rather than estimated.
|
||||||
|
static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06
|
||||||
|
static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07
|
||||||
|
static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81
|
||||||
|
static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09
|
||||||
|
|
||||||
|
static func color(for status: FuelSavingsStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive: return saves
|
||||||
|
case .notWorthTheDrive: return costs
|
||||||
|
case .breakEven: return breakEven
|
||||||
|
case .baseline: return closest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The "How Best Value works" explanation as its own sheet.
|
||||||
|
///
|
||||||
|
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
|
||||||
|
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||||
|
/// unchanged (the same `Localizable.strings` keys are reused — the old section
|
||||||
|
/// header string is the sheet's title).
|
||||||
|
struct HowBestValueSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
||||||
|
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
||||||
|
// The whole point of the sheet is to explain the feature,
|
||||||
|
// so the way to switch it on lives here rather than only
|
||||||
|
// in Settings. Full-width: this is the card's one action.
|
||||||
|
Button {
|
||||||
|
openBestValueSettings()
|
||||||
|
} label: {
|
||||||
|
Text("Setup Best Value")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
// `.large` is the standard iOS button height (~50pt): the
|
||||||
|
// default `.borderedProminent` is considerably shorter and
|
||||||
|
// read as a compact control rather than a button.
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("The working, step by step") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
step("1. The baseline",
|
||||||
|
"The closest station by journey distance. It is the trip you would make anyway, so everything else is measured against it.")
|
||||||
|
step("2. The return trip",
|
||||||
|
"For each station we work out the journey there and back. Where the roads have been measured we use the real driving route; until then we double the straight-line distance and mark the figure “Approx.”.")
|
||||||
|
step("3. Extra return travel",
|
||||||
|
"This station's return trip minus the baseline's. It is the extra you add on top of the journey you were already making — not the whole trip. Because both figures are return trips, it can read larger than the one-way distance shown above the map.")
|
||||||
|
step("4. Extra driving cost",
|
||||||
|
"What that extra distance costs in fuel: the extra miles, divided by your MPG, priced at the station you are driving to.")
|
||||||
|
step("5. Fuel-price saving",
|
||||||
|
"What the difference in price per litre is worth on your fill-up.")
|
||||||
|
step("6. Total",
|
||||||
|
"The fuel-price saving minus the extra driving cost. If the drive costs more than the price saves, the total is shown as an extra cost instead, and the station is not Best Value.")
|
||||||
|
step("7. Best Value",
|
||||||
|
"The total clears the minimum saving you set in Settings → Fuel Savings.")
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
|
||||||
|
Text("With Litres, every station is compared for the fixed quantity you selected.")
|
||||||
|
Text("Results are marked “Approx.” until road distances are available.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("How Best Value works")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.fraction(0.72)])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook: `-qaSetupBestValue` runs the Setup Best Value action without a
|
||||||
|
// tap (simctl cannot tap), so the tab switch can be screenshotted.
|
||||||
|
.task {
|
||||||
|
guard ProcessInfo.processInfo.arguments.contains("-qaSetupBestValue") else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
openBestValueSettings()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes this sheet and asks the app to land on Settings → Best Value, whose
|
||||||
|
/// section is the first thing in its list and holds the fill-up amount, MPG
|
||||||
|
/// and minimum saving. This sheet sits two levels below the tab view — More
|
||||||
|
/// info sheet, or the Key sheet — so it cannot switch tabs itself: it closes
|
||||||
|
/// itself and posts, and ContentView does the rest.
|
||||||
|
private func openBestValueSettings() {
|
||||||
|
dismiss()
|
||||||
|
// Announce only once this sheet has animated away. The host then closes
|
||||||
|
// the sheet this one was presented from — the More info sheet, or the Key
|
||||||
|
// sheet — and switches to Settings. Asking for that while the child was
|
||||||
|
// still dismissing could drop one of the two dismissals, which is what
|
||||||
|
// left the Settings tab covered by a sheet.
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
NotificationCenter.default.post(name: .fuelBoardOpenBestValueSettings, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One numbered step: a lead-in, then the plain explanation. Same words
|
||||||
|
/// either way — this only sets the hierarchy.
|
||||||
|
private func step(_ title: String, _ explanation: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(explanation)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
|
||||||
|
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
|
||||||
|
/// row-wide tap target that could swallow a neighbouring control (see the
|
||||||
|
/// two-buttons-in-one-row rule).
|
||||||
|
struct HowBestValueRow: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(KeySheetContent.helpTitle)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
|
||||||
|
/// be screenshotted (simctl has no tap command) — the same pattern as
|
||||||
|
/// `-showKeySheet` / `-showDetail`.
|
||||||
|
enum BestValueInfoQA {
|
||||||
|
static var isRequested: Bool {
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// "Important information" — FuelBoard's prices, availability, savings, journey
|
||||||
|
/// and liability disclaimer as ONE reusable sheet.
|
||||||
|
///
|
||||||
|
/// The disclaimer is shown from more than one place, so the copy and its layout
|
||||||
|
/// live here once: a host adds an `ImportantInformationRow` (or the
|
||||||
|
/// `.importantInformationSheet(isPresented:)` modifier) and the wording can
|
||||||
|
/// never drift between entry points.
|
||||||
|
///
|
||||||
|
/// One `Section` per topic, so every heading gets its own card: an accent
|
||||||
|
/// symbol beside a semibold title, with the secondary-coloured description
|
||||||
|
/// beneath. First host: the Stations tab's Key sheet (a row at the bottom). Copy
|
||||||
|
/// lives in `Localizable.strings`, keyed by the English source string, so the web
|
||||||
|
/// copy editor covers it.
|
||||||
|
struct ImportantInformationSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Width of the symbol column that every heading's icon sits in.
|
||||||
|
///
|
||||||
|
/// Symbols have wildly different ink widths — `car.side` is ~34 pt wide at
|
||||||
|
/// this size while `info.circle` is ~20 pt — so without a shared column each
|
||||||
|
/// heading's TEXT would start at a different x (measured: 180 px vs 233 px
|
||||||
|
/// at 3x), which is exactly the inconsistent alignment the brief rules out.
|
||||||
|
/// `@ScaledMetric` keeps the column in step with the icon font, so it still
|
||||||
|
/// can't overrun at large Dynamic Type sizes.
|
||||||
|
@ScaledMetric(relativeTo: .title3) private var iconSlot: CGFloat = 34
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.",
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Prices", systemImage: "sterlingsign.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.",
|
||||||
|
"We recommend checking availability before making a special journey."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Availability", systemImage: "fuelpump.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.",
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Savings & Best Value", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.",
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Journey Estimates", systemImage: "car.side")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Before You Travel", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.",
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Disclaimer", systemImage: "info.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The brief asks for ~24 pt between topics; the list's own default is
|
||||||
|
// wider and unsettable, this pins it.
|
||||||
|
.listSectionSpacing(.custom(24))
|
||||||
|
.navigationTitle("Important information")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section heading: accent symbol + semibold title, laid out horizontally
|
||||||
|
/// and vertically centred, with the symbol at a consistent size in every
|
||||||
|
/// section.
|
||||||
|
///
|
||||||
|
/// `.title3` is 20 pt at the default Dynamic Type size — inside the 18–20 pt
|
||||||
|
/// brief — and still scales with the user's text size, so the symbols stay in
|
||||||
|
/// proportion to the headings rather than drifting apart on large sizes.
|
||||||
|
///
|
||||||
|
/// The symbol is decorative (the heading text already carries the meaning),
|
||||||
|
/// so it is hidden from VoiceOver to stop it being announced as a stray glyph.
|
||||||
|
private func heading(_ title: String, systemImage: String) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
// Same-width column for every symbol, so all six headings'
|
||||||
|
// text starts at one x regardless of how wide the glyph is.
|
||||||
|
.frame(width: iconSlot)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(title))
|
||||||
|
.font(.headline)
|
||||||
|
// `Color.primary`, not `.primary`: the bare ShapeStyle resolves
|
||||||
|
// against the List header's own secondary style and comes out
|
||||||
|
// grey — measured (152,152,159), i.e. identical to the body text,
|
||||||
|
// which throws away the heading/description hierarchy.
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
// Let a long heading wrap instead of truncating on a narrow
|
||||||
|
// device or at a large text size.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card's paragraphs, stacked as prose rather than as separate `List` rows
|
||||||
|
/// so the card reads as one statement and the list draws no hairline between
|
||||||
|
/// a topic's own sentences.
|
||||||
|
///
|
||||||
|
/// `LocalizedStringKey` is what makes a `String` parameter localisable —
|
||||||
|
/// `Text(someString)` would render verbatim and the copy editor could never
|
||||||
|
/// reach it.
|
||||||
|
@ViewBuilder
|
||||||
|
private func paragraphs(_ lines: String...) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text(LocalizedStringKey(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row that opens `ImportantInformationSheet`. Reusable: drop it in any
|
||||||
|
/// `List` section.
|
||||||
|
///
|
||||||
|
/// `.buttonStyle(.plain)` keeps it from becoming a row-wide tap target that
|
||||||
|
/// could swallow a neighbouring control (see the two-buttons-in-one-row rule).
|
||||||
|
struct ImportantInformationRow: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Important information")
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Presents the shared "Important information" disclaimer. One call site per
|
||||||
|
/// surface — the sheet itself is never duplicated.
|
||||||
|
func importantInformationSheet(isPresented: Binding<Bool>) -> some View {
|
||||||
|
sheet(isPresented: isPresented) { ImportantInformationSheet() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// QA hook support: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
/// disclaimer on top of it, so the copy can be screenshotted without a tap
|
||||||
|
/// (simctl has no tap command) — the same chained pattern as
|
||||||
|
/// `-showBestValueInfo`. The disclaimer's `.sheet` lives inside the Key sheet's
|
||||||
|
/// content, so it cannot present while the Key sheet is shut.
|
||||||
|
enum ImportantInformationQA {
|
||||||
|
static var isRequested: Bool {
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-showImportantInfo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -79,7 +79,7 @@ enum LiveActivityManager {
|
|||||||
currentStartDate = nil
|
currentStartDate = nil
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
} else {
|
} else {
|
||||||
Task { await current.update(using: state) }
|
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ struct OnboardingView: View {
|
|||||||
private var welcomePage: some View {
|
private var welcomePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
|
||||||
// The app icon itself, with the standard iOS rounded-corner
|
// The app icon itself, with the standard iOS rounded-corner
|
||||||
// treatment (~22% of the size), so onboarding matches the
|
// treatment (~22% of the size), so onboarding matches the
|
||||||
// Home Screen.
|
// Home Screen.
|
||||||
@@ -108,9 +108,9 @@ struct OnboardingView: View {
|
|||||||
.frame(width: 96, height: 96)
|
.frame(width: 96, height: 96)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
|
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
||||||
.font(.largeTitle.bold())
|
.font(.largeTitle.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -121,12 +121,13 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
|
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
|
|
||||||
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
|
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
|
|
||||||
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
|
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
|
|
||||||
|
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ struct OnboardingView: View {
|
|||||||
permissionStatusLabel(
|
permissionStatusLabel(
|
||||||
granted: prompter.locationGranted,
|
granted: prompter.locationGranted,
|
||||||
denied: prompter.locationDenied,
|
denied: prompter.locationDenied,
|
||||||
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text")
|
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||||
)
|
)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
|
|
||||||
@@ -298,18 +299,23 @@ struct OnboardingView: View {
|
|||||||
OnboardingPrimaryButton(title: title, action: action)
|
OnboardingPrimaryButton(title: title, action: action)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureRow(icon: String, text: String) -> some View {
|
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
.frame(width: 32)
|
.frame(width: 32)
|
||||||
Text(text)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 36)
|
.padding(.horizontal, 36)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||||
@@ -393,6 +399,9 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
|||||||
|
|
||||||
let tint: Color
|
let tint: Color
|
||||||
var motion: Motion = .gentle
|
var motion: Motion = .gentle
|
||||||
|
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
||||||
|
/// the app icon itself and reads better with no container around it.
|
||||||
|
var showsHalo: Bool = true
|
||||||
@ViewBuilder var content: Content
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
@State private var settled = false
|
@State private var settled = false
|
||||||
@@ -402,6 +411,7 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
if showsHalo {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(tint.opacity(0.10))
|
.fill(tint.opacity(0.10))
|
||||||
.frame(width: 108, height: 108)
|
.frame(width: 108, height: 108)
|
||||||
@@ -412,6 +422,7 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
|||||||
.frame(width: 118, height: 118)
|
.frame(width: 118, height: 118)
|
||||||
.scaleEffect(settled ? 1.03 : 0.97)
|
.scaleEffect(settled ? 1.03 : 0.97)
|
||||||
.opacity(settled ? 1.0 : 0.55)
|
.opacity(settled ? 1.0 : 0.55)
|
||||||
|
}
|
||||||
|
|
||||||
content
|
content
|
||||||
.scaleEffect(iconScale)
|
.scaleEffect(iconScale)
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||||
content.body = "\(station.name) could leave you \(result.headline()). Tap for directions."
|
content.body = "\(station.name): \(result.headline()). Tap for directions."
|
||||||
addAlertRequest(content: content, station: station)
|
addAlertRequest(content: content, station: station)
|
||||||
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
|
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
|
||||||
lastAlert = "Best Value · \(brand) · \(result.headline())"
|
lastAlert = "Best Value · \(brand) · \(result.headline())"
|
||||||
@@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
content.sound = .default
|
content.sound = .default
|
||||||
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
|
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
|
||||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||||
content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions."
|
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
|
||||||
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
|
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
|
||||||
lastTestResult = baseResult
|
lastTestResult = baseResult
|
||||||
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
|
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// every row at once; the mode must also be ON for any pass to run at all.
|
// every row at once; the mode must also be ON for any pass to run at all.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
import MapKit
|
import MapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
@@ -131,7 +132,7 @@ enum RoadDistanceService {
|
|||||||
// visible to show for it: no pass, no requests, no widget reloads.
|
// visible to show for it: no pass, no requests, no widget reloads.
|
||||||
guard FuelStore.accurateDistancesEnabled else {
|
guard FuelStore.accurateDistancesEnabled else {
|
||||||
await updateDebug(baseDebugStatus(state: .gated,
|
await updateDebug(baseDebugStatus(state: .gated,
|
||||||
reason: "Accurate distances off",
|
reason: "Driving distances off",
|
||||||
stations: stations,
|
stations: stations,
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lng: lng,
|
lng: lng,
|
||||||
@@ -407,8 +408,14 @@ enum RoadDistanceService {
|
|||||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
let request = MKDirections.Request()
|
let request = MKDirections.Request()
|
||||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
request.source = MKMapItem(
|
||||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
location: CLLocation(latitude: from.latitude, longitude: from.longitude),
|
||||||
|
address: nil
|
||||||
|
)
|
||||||
|
request.destination = MKMapItem(
|
||||||
|
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
|
||||||
|
address: nil
|
||||||
|
)
|
||||||
request.transportType = .automobile
|
request.transportType = .automobile
|
||||||
request.requestsAlternateRoutes = false
|
request.requestsAlternateRoutes = false
|
||||||
do {
|
do {
|
||||||
|
|||||||
+145
-49
@@ -20,6 +20,12 @@ struct SettingsView: View {
|
|||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
@Binding var stationTapAction: StationTapAction
|
@Binding var stationTapAction: StationTapAction
|
||||||
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
||||||
|
/// The Best Value gate — the net saving that makes a station worth the trip.
|
||||||
|
/// Edited here, with the fill size and MPG that PRODUCE that saving, rather
|
||||||
|
/// than in the Alerts tab: the same figure also decides which station rows
|
||||||
|
/// carry the BEST VALUE badge, so it is a fuel-savings setting first and an
|
||||||
|
/// alert setting second. Stored under the alerts key either way.
|
||||||
|
@Binding var bestValueThresholdGBP: Double
|
||||||
@Binding var dataRefreshMode: DataRefreshMode
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
@@ -64,11 +70,21 @@ struct SettingsView: View {
|
|||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
||||||
/// "Accurate distances" — read once on appear and written straight through to
|
/// "Driving distances" — read once on appear and written straight through to
|
||||||
/// the shared store, which is what every display surface actually consults.
|
/// the shared store, which is what every display surface actually consults.
|
||||||
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
||||||
|
/// Slider drafts — the value under the thumb while it is down. `nil` means
|
||||||
|
/// "not dragging", in which case the stored setting is shown, so an external
|
||||||
|
/// change (a unit switch, the in-list control, a QA hook) is picked up with
|
||||||
|
/// no re-seeding. Dragging writes only the draft; the setting is saved once,
|
||||||
|
/// when the drag ends — see `commitMPG` / `commitFillUp`.
|
||||||
|
@State private var mpgDraft: Double?
|
||||||
|
@State private var fillUpDraft: Double?
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
|
/// The shared "Important information" disclaimer sheet, opened from the row
|
||||||
|
/// at the bottom of the tab.
|
||||||
|
@State private var showImportantInformation = false
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
||||||
@State private var smallWidgetDiag: String?
|
@State private var smallWidgetDiag: String?
|
||||||
@@ -110,6 +126,9 @@ struct SettingsView: View {
|
|||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
||||||
|
Text("Find stations that could save you money, even after the cost of driving there.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
// The feature's own controls follow its toggle: switched
|
// The feature's own controls follow its toggle: switched
|
||||||
// off they grey out but stay on screen (same rule as the
|
// off they grey out but stay on screen (same rule as the
|
||||||
// distance picker in Closest mode) and keep their values, so
|
// distance picker in Closest mode) and keep their values, so
|
||||||
@@ -125,11 +144,20 @@ struct SettingsView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: fuelSavingsMPGBinding,
|
// Draft-bound: the thumb moves a local value and the
|
||||||
|
// setting is written once, when the drag ends. Writing
|
||||||
|
// durable app-wide state on every step is what made this
|
||||||
|
// slider stutter.
|
||||||
|
Slider(value: mpgSliderValue,
|
||||||
in: FuelSavingsCalculator.mpgSliderRange,
|
in: FuelSavingsCalculator.mpgSliderRange,
|
||||||
step: FuelSavingsCalculator.mpgSliderStep)
|
step: FuelSavingsCalculator.mpgSliderStep) { editing in
|
||||||
|
if !editing { commitMPG() }
|
||||||
|
}
|
||||||
.accessibilityLabel("Average fuel economy")
|
.accessibilityLabel("Average fuel economy")
|
||||||
.accessibilityValue(mpgText)
|
.accessibilityValue(mpgText)
|
||||||
|
Text("How many miles your car travels per gallon.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
Text(unit.displayName).tag(unit)
|
Text(unit.displayName).tag(unit)
|
||||||
@@ -137,28 +165,47 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
HStack {
|
HStack {
|
||||||
Text("Typical fill-up")
|
Text("Fill-up amount")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(fillUpText)
|
Text(fillUpText)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Slider(value: fuelSavingsAmountBinding,
|
Slider(value: fillUpSliderValue,
|
||||||
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
||||||
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
|
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in
|
||||||
.accessibilityLabel("Typical fill-up")
|
if !editing { commitFillUp() }
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Fill-up amount")
|
||||||
.accessibilityValue(fillUpText)
|
.accessibilityValue(fillUpText)
|
||||||
|
Text(fillUpAmountDescription)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
||||||
|
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
||||||
|
Text(FuelSavingsFormat.money(value)).tag(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persisted by ContentView's single
|
||||||
|
// `.onChange(of: alertsBestValueThresholdGBP)`, which
|
||||||
|
// also refreshes the alert monitor — so this picker
|
||||||
|
// needs no handler of its own. The store snaps to the
|
||||||
|
// nearest option, so the picker and the stored value
|
||||||
|
// cannot disagree.
|
||||||
|
Text("The smallest saving you'd consider worth the extra drive.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
||||||
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
||||||
.font(.caption)
|
.font(.footnote)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!fuelSavingsSettings.enabled)
|
.fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Fuel Savings")
|
Text("Fuel Savings")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Best Value estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
|
Text("FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -173,6 +220,9 @@ struct SettingsView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
WatchSyncManager.shared.pushSnapshot()
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
|
Text("Choose how distances are displayed throughout FuelBoard.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
Picker("Price display", selection: $priceDisplayStyle) {
|
Picker("Price display", selection: $priceDisplayStyle) {
|
||||||
ForEach(PriceDisplayStyle.allCases) { style in
|
ForEach(PriceDisplayStyle.allCases) { style in
|
||||||
Text(style.displayName).tag(style)
|
Text(style.displayName).tag(style)
|
||||||
@@ -184,24 +234,26 @@ struct SettingsView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
WatchSyncManager.shared.pushSnapshot()
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
|
Text("Show prices as 129.9p or £1.299 per litre.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Units")
|
Text("Units")
|
||||||
} footer: {
|
|
||||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Accurate distances", isOn: $accurateDistances)
|
Toggle("Driving distances", isOn: $accurateDistances)
|
||||||
.onChange(of: accurateDistances) { _, newValue in
|
.onChange(of: accurateDistances) { _, newValue in
|
||||||
FuelStore.setAccurateDistancesEnabled(newValue)
|
FuelStore.setAccurateDistancesEnabled(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
||||||
object: nil)
|
object: nil)
|
||||||
}
|
}
|
||||||
|
Text("Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Distances")
|
Text("Distances")
|
||||||
} footer: {
|
|
||||||
Text("Off (default): distances are straight-line estimates marked Approx. and the app makes no route requests. On: Apple Maps driving distances for every station in range, filled only while this app is open. Search radii then mean miles of driving.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -214,10 +266,11 @@ struct SettingsView: View {
|
|||||||
.onChange(of: stationTapAction) { _, newValue in
|
.onChange(of: stationTapAction) { _, newValue in
|
||||||
FuelStore.saveStationTapAction(newValue)
|
FuelStore.saveStationTapAction(newValue)
|
||||||
}
|
}
|
||||||
|
Text("Choose what happens when you tap a station: get directions or view its details.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Tap action")
|
Text("Tap action")
|
||||||
} footer: {
|
|
||||||
Text("Tapping a station either opens directions straight away or shows its details first.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -234,14 +287,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(dataRefreshMode.summary)
|
Text(dataRefreshMode.summary)
|
||||||
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
|
Text("Background updates depend on when your iPhone allows them.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Data checking")
|
Text("Data checking")
|
||||||
} footer: {
|
|
||||||
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -250,8 +301,9 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
Label("Show introduction", systemImage: "arrow.clockwise.circle")
|
||||||
}
|
}
|
||||||
} footer: {
|
Text("View the welcome screens and set up permissions again.")
|
||||||
Text("Replay the welcome screen, including the location and notification permission prompts.")
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if debugMode {
|
if debugMode {
|
||||||
@@ -285,7 +337,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||||
}
|
}
|
||||||
.onAppear { onRefreshDebugStatus() }
|
.onAppear { onRefreshDebugStatus() }
|
||||||
|
|
||||||
@@ -428,7 +480,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Road distance fill")
|
Text("Road distance fill")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
Text("Shows whether Driving distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -533,7 +585,7 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(tipStore.purchaseInProgress)
|
.fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -558,11 +610,16 @@ struct SettingsView: View {
|
|||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ImportantInformationRow { showImportantInformation = true }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||||
.keyboardDoneButton { focusedSavingsField = nil }
|
.keyboardDoneButton { focusedSavingsField = nil }
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await tipStore.load() }
|
Task { await tipStore.load() }
|
||||||
}
|
}
|
||||||
@@ -684,59 +741,98 @@ struct SettingsView: View {
|
|||||||
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.enabled },
|
get: { fuelSavingsSettings.enabled },
|
||||||
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
|
set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fuelSavingsMPGBinding: Binding<Double> {
|
/// Slider values, draft-first: while the thumb is down the draft is what the
|
||||||
|
/// control and its read-out show; with no drag in progress the store's value
|
||||||
|
/// shows, so any external change (unit switch, in-list control, QA hook) is
|
||||||
|
/// reflected without re-seeding anything.
|
||||||
|
private var mpgSliderValue: Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.mpg },
|
get: { mpgDraft ?? fuelSavingsSettings.mpg },
|
||||||
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
|
set: { mpgDraft = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fuelSavingsAmountBinding: Binding<Double> {
|
private var fillUpSliderValue: Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.value },
|
get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
|
||||||
set: {
|
set: { fillUpDraft = $0 }
|
||||||
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
|
||||||
persistFuelSavingsSettings()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drag has ended: write the value once, and only if it actually moved.
|
||||||
|
/// The draft is cleared either way, so the control falls back to the stored
|
||||||
|
/// setting and shows what was saved — the clamps can lower it.
|
||||||
|
private func commitMPG() {
|
||||||
|
defer { mpgDraft = nil }
|
||||||
|
guard let draft = mpgDraft else { return }
|
||||||
|
let mpg = min(max(draft, 0), FuelSavingsCalculator.maximumMPG)
|
||||||
|
guard mpg != fuelSavingsSettings.mpg else { return }
|
||||||
|
fuelSavingsSettings.mpg = mpg
|
||||||
|
saveFuelSavingsField(.mpg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commitFillUp() {
|
||||||
|
defer { fillUpDraft = nil }
|
||||||
|
guard let draft = fillUpDraft else { return }
|
||||||
|
let amount = min(max(draft, 0),
|
||||||
|
FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
||||||
|
guard amount != fuelSavingsSettings.preferredAmount.value else { return }
|
||||||
|
fuelSavingsSettings.preferredAmount.value = amount
|
||||||
|
saveFuelSavingsField(.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the one field that changed. Saving the whole settings object
|
||||||
|
/// rewrites all four keys, which is only right when more than one has moved.
|
||||||
|
private func saveFuelSavingsField(_ field: FuelStore.FuelSavingsField) {
|
||||||
|
FuelStore.saveFuelSavingsField(field, from: fuelSavingsSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { fuelSavingsSettings.preferredAmount.unit },
|
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||||
set: { newUnit in
|
set: { newUnit in
|
||||||
fuelSavingsSettings.preferredAmount.unit = newUnit
|
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
||||||
if !fuelSavingsSettings.preferredAmount.isValid {
|
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
||||||
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30
|
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
||||||
}
|
// The unit and the amount move together, so both keys are written.
|
||||||
persistFuelSavingsSettings()
|
saveFuelSavingsField(.unit)
|
||||||
|
saveFuelSavingsField(.amount)
|
||||||
|
// A drag in progress on the old scale no longer maps to this one.
|
||||||
|
fillUpDraft = nil
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Describes the fill-up amount in the unit the user picked, so the copy stays
|
||||||
|
/// true whether the amount is a budget in pounds or a volume in litres.
|
||||||
|
private var fillUpAmountDescription: String {
|
||||||
|
switch fuelSavingsSettings.preferredAmount.unit {
|
||||||
|
case .pounds: return "How much you plan to spend on fuel."
|
||||||
|
case .litres: return "How many litres you usually buy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
||||||
/// is the one place the figure is stated.
|
/// is the one place the figure is stated. It follows the draft while the
|
||||||
|
/// thumb is down.
|
||||||
private var mpgText: String {
|
private var mpgText: String {
|
||||||
let mpg = fuelSavingsSettings.mpg
|
let mpg = mpgDraft ?? fuelSavingsSettings.mpg
|
||||||
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "£40" / "40 L" — the fill-up slider's read-out.
|
/// "£40" / "40 L" — the fill-up slider's read-out, draft-first like the MPG one.
|
||||||
private var fillUpText: String {
|
private var fillUpText: String {
|
||||||
|
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
|
||||||
switch fuelSavingsSettings.preferredAmount.unit {
|
switch fuelSavingsSettings.preferredAmount.unit {
|
||||||
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
|
case .pounds: return String(format: "£%.0f", value)
|
||||||
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
|
case .litres: return String(format: "%.0f L", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistFuelSavingsSettings() {
|
|
||||||
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||||
private var connectionText: String {
|
private var connectionText: String {
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The More info sheet's facts row: the selected fuel's price on the left, the
|
||||||
|
/// distance on the right, separated by a hairline down the middle.
|
||||||
|
///
|
||||||
|
/// Every figure comes from the sheet's existing sources — the price through
|
||||||
|
/// `FuelStore.priceText` and the distance through
|
||||||
|
/// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with
|
||||||
|
/// the listing row, the widget or the Live Activity, and both columns keep the
|
||||||
|
/// shared "Approx." confidence marking.
|
||||||
|
struct StationFactsRow: View {
|
||||||
|
let station: FuelStation
|
||||||
|
let selectedFuel: FuelType
|
||||||
|
let priceAge: FuelPriceFreshness.Assessment
|
||||||
|
let distance: StationDistanceDisplay?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
|
/// The user's price display style, from the same Settings choice the listing
|
||||||
|
/// rows and the widgets read.
|
||||||
|
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
|
|
||||||
|
/// The price's point size, scaled with Dynamic Type: the price is built from
|
||||||
|
/// an explicit size (its "/L" suffix is sized relative to it inside
|
||||||
|
/// `priceTextAttributed`), so it needs a metric rather than a font style.
|
||||||
|
/// 22pt matches the listing row's price.
|
||||||
|
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
fuelColumn
|
||||||
|
StationFactsDivider()
|
||||||
|
distanceColumn
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fuelColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(selectedFuel.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
priceView
|
||||||
|
StationPriceAgeView(assessment: priceAge, compact: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var distanceColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("Distance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(distanceText)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(returnText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selected grade's price, in the user's chosen display style — the same
|
||||||
|
/// `priceTextAttributed` the listing row uses, so the sheet prints the bare
|
||||||
|
/// forecourt figure ("167.9") in Station sign and the superscripted
|
||||||
|
/// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree.
|
||||||
|
/// The returned Text carries its own fonts, so no `.font(...)` goes on top.
|
||||||
|
///
|
||||||
|
/// A missing grade shows an em dash rather than hiding the column, so the row
|
||||||
|
/// keeps its two-column shape.
|
||||||
|
@ViewBuilder
|
||||||
|
private var priceView: some View {
|
||||||
|
if let price = station.prices[selectedFuel] {
|
||||||
|
FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold)
|
||||||
|
} else {
|
||||||
|
Text("—")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-way distance, with its confidence marker, from the shared display.
|
||||||
|
private var distanceText: String {
|
||||||
|
distance?.label(unit: distanceUnit) ?? "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Out-and-back distance, same source and same marker rule.
|
||||||
|
private var returnText: String {
|
||||||
|
distance?.returnLabel(unit: distanceUnit) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-pixel vertical hairline between the facts row's two columns, drawn in
|
||||||
|
/// the List's own separator colour so it matches every other line on the sheet.
|
||||||
|
/// Decorative: VoiceOver reads each column as one phrase instead.
|
||||||
|
private struct StationFactsDivider: View {
|
||||||
|
var body: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(.separator))
|
||||||
|
.frame(width: 0.5)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The More info sheet's Best Value card: the verdict, then the figures it is
|
||||||
|
/// built from.
|
||||||
|
///
|
||||||
|
/// Nothing is recomputed here — every number comes from the shared
|
||||||
|
/// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the
|
||||||
|
/// listing rows use, and the total is the shared `headline()`. Wording and
|
||||||
|
/// colour therefore cannot drift between the sheet and the list.
|
||||||
|
struct BestValueDetailCard: View {
|
||||||
|
let availability: FuelSavingsAvailability
|
||||||
|
let amount: FuelSavingsFillAmount
|
||||||
|
let baselineStationName: String?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch availability {
|
||||||
|
case .available(let result):
|
||||||
|
availableBody(result)
|
||||||
|
case .unavailable(let reason):
|
||||||
|
Text("Best Value unavailable: \(reason)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func availableBody(_ result: FuelSavingsResult) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
header(result)
|
||||||
|
panels(result)
|
||||||
|
workings(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card title on the left, the verdict on the right — ONE capsule, in the
|
||||||
|
/// same large form the sheet used to repeat on its own line above the
|
||||||
|
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
|
||||||
|
/// with it, the same way the listing rows lay the pair out.
|
||||||
|
private func header(_ result: FuelSavingsResult) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
|
Text("Best Value")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
FuelSavingsCapsule(result: result, compact: false)
|
||||||
|
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
|
||||||
|
comparison(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compact comparison caption ("vs closest station" / "your baseline"),
|
||||||
|
/// under the capsule it belongs to.
|
||||||
|
private func comparison(_ result: FuelSavingsResult) -> some View {
|
||||||
|
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two figures the estimate is made OF, side by side: what the user
|
||||||
|
/// spends, and how much fuel that buys at this station.
|
||||||
|
private func panels(_ result: FuelSavingsResult) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
panel(symbol: "fuelpump.fill",
|
||||||
|
title: "Your fill-up",
|
||||||
|
value: Self.amountText(amount))
|
||||||
|
panel(symbol: "drop.fill",
|
||||||
|
title: "Fuel you'll receive",
|
||||||
|
value: String(format: "%.2f litres", result.fillLitres))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func panel(symbol: String, title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
Text(value)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06)))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The working: what the extra travel adds, what the pump saves, and the
|
||||||
|
/// total. A baseline station has nothing to save against itself, so it stops
|
||||||
|
/// after the panels — the capsule above already says "Closest station".
|
||||||
|
@ViewBuilder
|
||||||
|
private func workings(_ result: FuelSavingsResult) -> some View {
|
||||||
|
if result.status != .baseline {
|
||||||
|
Divider()
|
||||||
|
extraTravelRow(result)
|
||||||
|
savingRows(result)
|
||||||
|
Divider()
|
||||||
|
totalRow(result)
|
||||||
|
if let baselineStationName {
|
||||||
|
Text("Compared with \(baselineStationName).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.journeyConfidence.isEstimated {
|
||||||
|
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extra driving a saving has to beat, named in the row label and
|
||||||
|
/// measured in the value — same figure and same "Approx." rule as the shared
|
||||||
|
/// caption, from the shared formatter.
|
||||||
|
@ViewBuilder
|
||||||
|
private func extraTravelRow(_ result: FuelSavingsResult) -> some View {
|
||||||
|
if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel(
|
||||||
|
extraDistanceMiles: result.extraDistanceMiles,
|
||||||
|
unit: distanceUnit,
|
||||||
|
confidence: result.journeyConfidence
|
||||||
|
) {
|
||||||
|
LabeledContent("Extra return travel", value: extraTravel)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func savingRows(_ result: FuelSavingsResult) -> some View {
|
||||||
|
LabeledContent(
|
||||||
|
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
|
||||||
|
value: result.grossSavingGBP >= 0
|
||||||
|
? FuelSavingsFormat.money(result.grossSavingGBP)
|
||||||
|
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
|
||||||
|
)
|
||||||
|
.monospacedDigit()
|
||||||
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Total row is the WORKINGS; the capsule above is the quick verdict.
|
||||||
|
/// Both take their wording and colour from the shared result.
|
||||||
|
private func totalRow(_ result: FuelSavingsResult) -> some View {
|
||||||
|
LabeledContent {
|
||||||
|
Text(result.headline())
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(FuelSavingsCapsule.color(for: result.status))
|
||||||
|
.monospacedDigit()
|
||||||
|
} label: {
|
||||||
|
Text("Total")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "£60" or "60 litres" — the same strings the sheet printed before the
|
||||||
|
/// redesign, so the fill-up reads identically in both units.
|
||||||
|
static func amountText(_ amount: FuelSavingsFillAmount) -> String {
|
||||||
|
switch amount.unit {
|
||||||
|
case .pounds: return String(format: "£%.0f", amount.value)
|
||||||
|
case .litres: return String(format: "%.0f litres", amount.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "How Best Value works" lives in the shared `HowBestValueRow`
|
||||||
|
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
|
||||||
|
/// the Key cannot drift.
|
||||||
@@ -17,6 +17,11 @@ struct StationDetailView: View {
|
|||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
|
/// Price display style, from the same Settings choice the listing rows read,
|
||||||
|
/// so the sheet's price can never disagree with the row behind it.
|
||||||
|
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
|
/// Fuel whose price is being shown by the surrounding station list.
|
||||||
|
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
var fuelSavings: FuelSavingsAvailability? = nil
|
var fuelSavings: FuelSavingsAvailability? = nil
|
||||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||||
var savingsBaselineStationName: String? = nil
|
var savingsBaselineStationName: String? = nil
|
||||||
@@ -29,10 +34,13 @@ struct StationDetailView: View {
|
|||||||
var onToggleFavourite: (() -> Void)? = nil
|
var onToggleFavourite: (() -> Void)? = nil
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingReportIssueConfirmation = false
|
@State private var showingReportIssueConfirmation = false
|
||||||
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
|
/// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
|
||||||
/// the caption fades out on its own.
|
/// the caption fades out on its own.
|
||||||
@State private var showingCopied = false
|
@State private var showingCopied = false
|
||||||
@State private var copyResetTask: Task<Void, Never>?
|
@State private var copyResetTask: Task<Void, Never>?
|
||||||
|
/// The "How Best Value works" sheet, opened from the row at the bottom of the
|
||||||
|
/// Best Value card (the Key sheet opens the same shared view).
|
||||||
|
@State private var showBestValueInfo = false
|
||||||
|
|
||||||
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
@@ -54,6 +62,84 @@ struct StationDetailView: View {
|
|||||||
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var priceFreshness: FuelPriceFreshness.Assessment {
|
||||||
|
FuelPriceFreshness.assessment(for: station)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-way and out-and-back distance from the current location, from the ONE
|
||||||
|
/// shared display helper — so this sheet's figures carry the same confidence
|
||||||
|
/// marker as the listing row, the widget and the Live Activity.
|
||||||
|
private var distanceDisplay: StationDistanceDisplay? {
|
||||||
|
guard let location else { return nil }
|
||||||
|
return FuelStore.stationDistanceDisplay(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header title: the station name over its address, as the mockup shows.
|
||||||
|
/// Tapping copies the postcode, and the "Postcode copied" capsule floats over
|
||||||
|
/// the address line it confirms.
|
||||||
|
///
|
||||||
|
/// The CARD'S FIRST ROW, above the map — deliberately not a toolbar
|
||||||
|
/// principal item: a principal item is clamped to one line, so a long
|
||||||
|
/// forecourt name ("Brighouse Bradford Rd Superstore - Petrol") showed an
|
||||||
|
/// ellipsis and `minimumScaleFactor` was not honoured. Inside the card it
|
||||||
|
/// wraps to two lines, and the confirmation capsule cannot be clipped by the
|
||||||
|
/// nav bar.
|
||||||
|
private var headerTitle: some View {
|
||||||
|
Button {
|
||||||
|
copyPostcode()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(station.name)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(addressLine)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(postcodeForCopying.isEmpty)
|
||||||
|
.accessibilityLabel(String(localized: "Address: \(addressLine)",
|
||||||
|
comment: "Accessibility label for the address in the More info sheet header"))
|
||||||
|
.accessibilityHint(postcodeForCopying.isEmpty
|
||||||
|
? ""
|
||||||
|
: String(localized: "Copies the postcode",
|
||||||
|
comment: "Accessibility hint for the address in the More info sheet header"))
|
||||||
|
.overlay(alignment: .center) { copiedConfirmation }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transient "Postcode copied" capsule. An overlay, so nothing moves
|
||||||
|
/// while it is up, and hit testing stays off so the header keeps its tap.
|
||||||
|
@ViewBuilder
|
||||||
|
private var copiedConfirmation: some View {
|
||||||
|
if showingCopied {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
Text("Postcode copied")
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
// A material base keeps it legible where the tint alone (the
|
||||||
|
// notice-capsule recipe) washes out.
|
||||||
|
.background {
|
||||||
|
Capsule()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
.overlay(Capsule().fill(Color.green.opacity(0.22)))
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps driving directions — the same destination (and the same
|
/// Opens Apple Maps driving directions — the same destination (and the same
|
||||||
/// URL) the Directions button below uses, so the map and the button can
|
/// URL) the Directions button below uses, so the map and the button can
|
||||||
/// never point at two different places.
|
/// never point at two different places.
|
||||||
@@ -73,8 +159,18 @@ struct StationDetailView: View {
|
|||||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
|
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
|
||||||
copyResetTask?.cancel()
|
copyResetTask?.cancel()
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook: `-qaCopyPostcode` holds the confirmation up long enough for a
|
||||||
|
// screenshot. The production window (2s) cannot be hit reliably — the app
|
||||||
|
// takes several seconds to render, and simctl cannot tap.
|
||||||
|
let hold: UInt64 = ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode")
|
||||||
|
? 25_000_000_000
|
||||||
|
: 2_000_000_000
|
||||||
|
#else
|
||||||
|
let hold: UInt64 = 2_000_000_000
|
||||||
|
#endif
|
||||||
copyResetTask = Task {
|
copyResetTask = Task {
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: hold)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
|
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
|
||||||
}
|
}
|
||||||
@@ -82,8 +178,18 @@ struct StationDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
// Wrapper exists only so the DEBUG scroll hook below can reach a
|
||||||
|
// section by id; it has no effect on layout or behaviour.
|
||||||
|
ScrollViewReader { proxy in
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
|
// The sheet header is the CARD'S FIRST ROW, above the map:
|
||||||
|
// the station name over its address, both wrapping rather
|
||||||
|
// than truncating, on the card's own insets so it lines up
|
||||||
|
// with the map and the rows below. Tapping it copies the
|
||||||
|
// postcode. No hairline under it — the map follows.
|
||||||
|
headerTitle
|
||||||
|
.listRowSeparator(.hidden, edges: .bottom)
|
||||||
// Tapping anywhere on the map opens the same Apple Maps
|
// Tapping anywhere on the map opens the same Apple Maps
|
||||||
// directions the Directions button below opens.
|
// directions the Directions button below opens.
|
||||||
//
|
//
|
||||||
@@ -123,77 +229,27 @@ struct StationDetailView: View {
|
|||||||
// clutter. The Address/Distance and Distance/actions lines
|
// clutter. The Address/Distance and Distance/actions lines
|
||||||
// stay.
|
// stay.
|
||||||
.listRowSeparator(.hidden, edges: .bottom)
|
.listRowSeparator(.hidden, edges: .bottom)
|
||||||
// The map, the address, the distance, any notices and the two
|
// The map, the facts row, any notices and the two station
|
||||||
// station actions all share ONE card (the list's own row
|
// actions all share ONE card (the list's own row hairlines
|
||||||
// hairlines separate them), so no hand-rolled Divider here —
|
// separate them), so no hand-rolled Divider here — adding one
|
||||||
// adding one would double the line.
|
// would double the line.
|
||||||
//
|
//
|
||||||
// The station name is NOT repeated: the sheet header already
|
// The station name AND the address live in the sheet header,
|
||||||
// shows it.
|
// which is also where a tap copies the postcode — see the
|
||||||
//
|
// principal toolbar item below.
|
||||||
// The whole row is a copy control: it puts JUST the postcode
|
// The price and the distance share one two-column row: each
|
||||||
// on the clipboard so it can be pasted into another
|
// figure comes from the same source it did before (the price
|
||||||
// navigation app. The copy glyph sits ON the address line,
|
// from FuelStore, the distance from the one shared display
|
||||||
// and the "Copied" confirmation is a capsule that floats OVER
|
// helper), so the row cannot disagree with the listing row,
|
||||||
// THIS ROW — it confirms the address, so it must not hover
|
// the widget or the Live Activity.
|
||||||
// over the map. It is an overlay, so nothing moves while it is
|
StationFactsRow(
|
||||||
// up, and `allowsHitTesting(false)` keeps the row tappable
|
station: station,
|
||||||
// through the capsule.
|
selectedFuel: selectedFuel,
|
||||||
Button {
|
priceAge: priceFreshness,
|
||||||
copyPostcode()
|
distance: distanceDisplay,
|
||||||
} label: {
|
distanceUnit: distanceUnit,
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
priceStyle: priceStyle
|
||||||
Text("Address")
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
Text(addressLine)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
// Stays the plain copy glyph — the capsule carries
|
|
||||||
// the confirmation, so swapping this to a checkmark
|
|
||||||
// would say it twice.
|
|
||||||
Image(systemName: "doc.on.doc")
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(postcodeForCopying.isEmpty)
|
|
||||||
.accessibilityLabel(String(localized: "Address: \(addressLine)",
|
|
||||||
comment: "Accessibility label for the address row in the More info sheet"))
|
|
||||||
.accessibilityHint(postcodeForCopying.isEmpty
|
|
||||||
? ""
|
|
||||||
: String(localized: "Copies the postcode",
|
|
||||||
comment: "Accessibility hint for the address row in the More info sheet"))
|
|
||||||
// Horizontally centred over the address line itself.
|
|
||||||
.overlay(alignment: .center) {
|
|
||||||
if showingCopied {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
Text("Copied")
|
|
||||||
}
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
// A material base keeps it legible where the tint
|
|
||||||
// alone (the notice-capsule recipe) washes out.
|
|
||||||
.background {
|
|
||||||
Capsule()
|
|
||||||
.fill(.regularMaterial)
|
|
||||||
.overlay(Capsule().fill(Color.green.opacity(0.22)))
|
|
||||||
}
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let location {
|
|
||||||
let distance = FuelStore.stationDistanceDisplay(
|
|
||||||
station: station, userLat: location.lat, userLng: location.lng
|
|
||||||
)
|
)
|
||||||
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
ForEach(StationNotice.notices(for: station)) { notice in
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
Label(notice.title, systemImage: notice.symbolName)
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -202,15 +258,19 @@ struct StationDetailView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
}
|
}
|
||||||
// The two station actions are the LAST row of the card: half
|
// Half the width each, on one line, with the list's hairline
|
||||||
// the width each, on one line, below Address and Distance.
|
// running between them.
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 0) {
|
||||||
Button {
|
Button {
|
||||||
openDirections()
|
openDirections()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(.separator))
|
||||||
|
.frame(width: 0.5, height: 22)
|
||||||
|
.accessibilityHidden(true)
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showingReportIssueConfirmation = true
|
showingReportIssueConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -230,15 +290,23 @@ struct StationDetailView: View {
|
|||||||
// is not configured — so the heading cannot be left dangling
|
// is not configured — so the heading cannot be left dangling
|
||||||
// over an empty section.
|
// over an empty section.
|
||||||
if let fuelSavings {
|
if let fuelSavings {
|
||||||
|
// The card's own header row says "Best Value", so a section
|
||||||
|
// header would repeat it; the green tint is what marks this
|
||||||
|
// block as the savings card.
|
||||||
Section {
|
Section {
|
||||||
FuelSavingsSummaryView(
|
BestValueDetailCard(
|
||||||
availability: fuelSavings,
|
availability: fuelSavings,
|
||||||
amount: fuelSavingsAmount,
|
amount: fuelSavingsAmount,
|
||||||
baselineStationName: savingsBaselineStationName,
|
baselineStationName: savingsBaselineStationName,
|
||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit
|
||||||
)
|
)
|
||||||
} header: {
|
}
|
||||||
Text("Best Value")
|
.listRowBackground(Color.green.opacity(0.08))
|
||||||
|
.id("bestValueCard")
|
||||||
|
|
||||||
|
Section {
|
||||||
|
// The shared row — same component the Key sheet shows.
|
||||||
|
HowBestValueRow { showBestValueInfo = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,8 +323,27 @@ struct StationDetailView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.navigationTitle(station.name)
|
// No navigationTitle: the sheet's header row carries the name and
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
// address, and an inline title would print the name a second time.
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook: `-qaDetailBottom` scrolls to the Best Value card so its
|
||||||
|
// lower half (Extra driving cost, Total, the estimate caveat) can be
|
||||||
|
// screenshotted. `simctl` cannot scroll, and `.defaultScrollAnchor`
|
||||||
|
// is a no-op on a List — only scrollTo moves it.
|
||||||
|
.task {
|
||||||
|
guard ProcessInfo.processInfo.arguments.contains("-qaDetailBottom") else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
withAnimation { proxy.scrollTo("bestValueCard", anchor: .bottom) }
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// QA hook: `-qaDetailShowBestValueInfo` opens the explanation from
|
||||||
|
// THIS sheet (the More info path), so the two-sheet dismissal can
|
||||||
|
// be screenshotted — simctl has no tap.
|
||||||
|
guard ProcessInfo.processInfo.arguments.contains("-qaDetailShowBestValueInfo") else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
showBestValueInfo = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
Button("Report") {
|
Button("Report") {
|
||||||
@@ -265,6 +352,8 @@ struct StationDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||||
}
|
}
|
||||||
|
// Nested sheet: the More info sheet stays put behind the explanation.
|
||||||
|
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") { dismiss() }
|
Button("Done") { dismiss() }
|
||||||
@@ -284,12 +373,12 @@ struct StationDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The "Copied" fade-out task must not outlive the sheet.
|
// The "Postcode copied" fade-out task must not outlive the sheet.
|
||||||
.onDisappear { copyResetTask?.cancel() }
|
.onDisappear { copyResetTask?.cancel() }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
||||||
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
||||||
// "Copied" confirmation can be verified without a tap — simctl
|
// "Postcode copied" confirmation can be verified without a tap — simctl
|
||||||
// cannot drive taps into the Simulator.
|
// cannot drive taps into the Simulator.
|
||||||
.task {
|
.task {
|
||||||
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
||||||
@@ -297,5 +386,8 @@ struct StationDetailView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
// Closes the ScrollViewReader wrapped around the List: kept last so
|
||||||
|
// every List modifier above stays inside `proxy`'s scope.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The More info sheet's price-age row.
|
||||||
|
///
|
||||||
|
/// Station-level by design: the relay publishes a single `price_updated` per
|
||||||
|
/// station (the newest grade it maps), so the copy says "Price updated" and
|
||||||
|
/// never claims the selected grade itself was updated.
|
||||||
|
///
|
||||||
|
/// Past `FuelPriceFreshness.outdatedAge` a discreet note flags the information
|
||||||
|
/// as possibly out of date. It is informational only — it gates nothing, and
|
||||||
|
/// the station's own price row above it is unaffected.
|
||||||
|
///
|
||||||
|
/// Its own view rather than inlined into `StationDetailView`: that body sits at
|
||||||
|
/// the compiler's type-checking limit, so one extra statement there fails the
|
||||||
|
/// build. One statement here, one there.
|
||||||
|
struct StationPriceAgeView: View {
|
||||||
|
let assessment: FuelPriceFreshness.Assessment
|
||||||
|
|
||||||
|
/// The More info sheet's facts row prints this under the fuel price inside a
|
||||||
|
/// half-width column, where a `LabeledContent`'s label + value pair cannot
|
||||||
|
/// fit on one line. Compact keeps the SAME words in one caption line.
|
||||||
|
var compact: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if compact {
|
||||||
|
Text("Price updated \(assessment.updateAgeLabel)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.accessibilityLabel("Price updated \(assessment.updateAgeLabel)")
|
||||||
|
} else {
|
||||||
|
LabeledContent("Price updated", value: assessment.updateAgeLabel)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
if let notice = assessment.outdatedNotice {
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+326
-124
@@ -1,6 +1,94 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
|
/// The "Key" legend: price ratings, savings capsules, station labels, the
|
||||||
|
/// favourite glyph, then the two deeper explanations. One sheet for both list
|
||||||
|
/// tabs — Stations opens it from the info button in the navigation bar,
|
||||||
|
/// Favourites from the button beside Edit — so the legend cannot drift between
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// Dismisses through the environment rather than the presenting view's state, and
|
||||||
|
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
|
||||||
|
/// QA launch-argument hooks.
|
||||||
|
struct KeySheetView: View {
|
||||||
|
/// The selected fuel's colour. `KeyStationLabelIndicator` draws its pump badge
|
||||||
|
/// in it, so the legend matches the rows the reader is looking at.
|
||||||
|
let fuelTint: Color
|
||||||
|
@Binding var showBestValueInfo: Bool
|
||||||
|
@Binding var showImportantInformation: Bool
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
|
||||||
|
}
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
|
||||||
|
}
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Price ratings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(KeySheetContent.savings, id: \.label) { item in
|
||||||
|
KeySheetDescriptionRow(item: item) {
|
||||||
|
KeySavingsCapsule(label: item.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Savings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(KeySheetContent.stationLabels, id: \.label) { item in
|
||||||
|
KeySheetDescriptionRow(item: item) {
|
||||||
|
KeyStationLabelIndicator(label: item.label, fuelColor: fuelTint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Station labels")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
|
||||||
|
FavouriteStarGlyph(isFavourite: true)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Favourites")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HowBestValueRow { showBestValueInfo = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ImportantInformationRow { showImportantInformation = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Key")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nested sheet: the Key sheet stays behind the explanation.
|
||||||
|
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||||
|
// Nested sheet: the Key sheet stays behind the disclaimer.
|
||||||
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||||
|
}
|
||||||
|
.presentationDetents([.fraction(0.6), .large])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
|
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
|
||||||
struct StationsView: View {
|
struct StationsView: View {
|
||||||
let stations: [FuelStation]
|
let stations: [FuelStation]
|
||||||
@@ -19,7 +107,6 @@ struct StationsView: View {
|
|||||||
let fuelSavingsSettings: FuelSavingsSettings
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
var savingsBaselineStationName: String?
|
|
||||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
@@ -35,6 +122,16 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// The "How Best Value works" sheet. The explanation used to be a section in
|
||||||
|
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
|
||||||
|
/// the Key sheet and from the More info sheet.
|
||||||
|
@State private var showBestValueInfo = false
|
||||||
|
|
||||||
|
/// The shared "Important information" disclaimer sheet. It lives at the
|
||||||
|
/// bottom of the Key sheet here; the same sheet is reused wherever else the
|
||||||
|
/// disclaimer is needed.
|
||||||
|
@State private var showImportantInformation = false
|
||||||
|
|
||||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
@State private var detailStation: FuelStation?
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
@@ -63,7 +160,7 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
if location != nil {
|
if location != nil {
|
||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — closest first, lowest price within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
@@ -89,7 +186,7 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
// In Closest mode the whole country is the pool — the
|
// In Closest mode the whole country is the pool — the
|
||||||
// radius is meaningless ("nearest" must never return an
|
// radius is meaningless ("closest" must never return an
|
||||||
// empty state), so the picker is disabled but its value is
|
// empty state), so the picker is disabled but its value is
|
||||||
// kept for when the user switches back to Cheapest.
|
// kept for when the user switches back to Cheapest.
|
||||||
.disabled(sortMode == .closest)
|
.disabled(sortMode == .closest)
|
||||||
@@ -116,10 +213,14 @@ struct StationsView: View {
|
|||||||
// stale stored sort mode must not keep the control on screen
|
// stale stored sort mode must not keep the control on screen
|
||||||
// for a switched-off feature.
|
// for a switched-off feature.
|
||||||
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
||||||
FuelSavingsAdjustmentView(
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
amount: $fuelSavingsAmount,
|
}
|
||||||
referencePricePencePerLitre: baselinePrice
|
|
||||||
)
|
if fuelSavingsSettings.isConfigured && !stations.isEmpty {
|
||||||
|
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLoading && stations.isEmpty {
|
if isLoading && stations.isEmpty {
|
||||||
@@ -144,22 +245,7 @@ struct StationsView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
||||||
StationRow(
|
stationRow(station)
|
||||||
station: station,
|
|
||||||
fuel: selectedFuel,
|
|
||||||
location: location,
|
|
||||||
distanceUnit: distanceUnit,
|
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
|
||||||
baselinePrice: baselinePrice,
|
|
||||||
isTopResult: station.id == topStationID,
|
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
|
||||||
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
|
||||||
tapAction: stationTapAction,
|
|
||||||
fuelSavings: savingsForStation(station),
|
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
|
||||||
onShowDetails: { detailStation = $0 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if visibleCount < stations.count {
|
if visibleCount < stations.count {
|
||||||
@@ -218,8 +304,40 @@ struct StationsView: View {
|
|||||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||||
showKey = true
|
showKey = true
|
||||||
}
|
}
|
||||||
|
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
|
||||||
|
// explanation on top of it, so the copy can be screenshotted
|
||||||
|
// without a tap. It has to chain: the explanation's `.sheet` lives
|
||||||
|
// inside the Key sheet's content, so it cannot present while the
|
||||||
|
// Key sheet is shut.
|
||||||
|
if BestValueInfoQA.isRequested {
|
||||||
|
showKey = true
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
showBestValueInfo = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// QA hook: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
// shared disclaimer on top of it, so the copy can be
|
||||||
|
// screenshotted without a tap. Same chained requirement as
|
||||||
|
// `-showBestValueInfo` above.
|
||||||
|
if ImportantInformationQA.isRequested {
|
||||||
|
showKey = true
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
showImportantInformation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||||
|
// "Setup Best Value" from the explanation sheet. That sheet closes
|
||||||
|
// itself and ContentView switches to Settings, but both of the
|
||||||
|
// sheets this view can have open are presented from HERE and each
|
||||||
|
// survived the tab switch, floating over Settings: the Key sheet
|
||||||
|
// and the More info sheet. Close whichever is up.
|
||||||
|
showKey = false
|
||||||
|
detailStation = nil
|
||||||
|
}
|
||||||
.task(id: stations.count) {
|
.task(id: stations.count) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: `-showDetail` opens the More info sheet for the first
|
// QA hook: `-showDetail` opens the More info sheet for the first
|
||||||
@@ -243,87 +361,55 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showKey) {
|
.sheet(isPresented: $showKey) {
|
||||||
NavigationStack {
|
keySheet
|
||||||
List {
|
|
||||||
Section {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
keyCard(
|
|
||||||
icon: "checkmark.circle.fill", color: .green,
|
|
||||||
title: "Best value", blurb: "Within 1.5p of the cheapest",
|
|
||||||
threshold: "≤1.5p")
|
|
||||||
keyCard(
|
|
||||||
icon: "equal.circle.fill", color: .orange,
|
|
||||||
title: "Okay", blurb: "Within 4p of the cheapest",
|
|
||||||
threshold: "≤4p")
|
|
||||||
keyCard(
|
|
||||||
icon: "exclamationmark.circle.fill", color: .red,
|
|
||||||
title: "Pricey", blurb: "More than 4p over the cheapest",
|
|
||||||
threshold: ">4p")
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text("TOP")
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(.blue.opacity(0.16)))
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
Text("Top result for the current sort")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .blue)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
.frame(width: 34)
|
|
||||||
Text("Star a station to add it to Favourites")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .yellow)
|
|
||||||
Text("Colours match each station's rating on the list")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Key")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") { showKey = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.presentationDetents([.fraction(0.6)])
|
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
}
|
}
|
||||||
.sheet(item: $detailStation) { station in
|
.sheet(item: $detailStation) { station in
|
||||||
|
detailSheet(for: station)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailSheet(for station: FuelStation) -> some View {
|
||||||
StationDetailView(
|
StationDetailView(
|
||||||
station: station,
|
station: station,
|
||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
|
priceStyle: priceDisplayStyle,
|
||||||
|
selectedFuel: selectedFuel,
|
||||||
fuelSavings: savingsForStation(station),
|
fuelSavings: savingsForStation(station),
|
||||||
fuelSavingsAmount: fuelSavingsAmount,
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
savingsBaselineStationName: savingsBaselineStationName,
|
|
||||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legend sheet, shared with the Favourites tab. Only the fuel differs:
|
||||||
|
/// its colour tints the station-label row, so the legend matches the rows the
|
||||||
|
/// reader is actually looking at.
|
||||||
|
private var keySheet: some View {
|
||||||
|
KeySheetView(
|
||||||
|
fuelTint: selectedFuel.tintColor,
|
||||||
|
showBestValueInfo: $showBestValueInfo,
|
||||||
|
showImportantInformation: $showImportantInformation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stationRow(_ station: FuelStation) -> some View {
|
||||||
|
StationRow(
|
||||||
|
station: station,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
|
baselinePrice: baselinePrice,
|
||||||
|
isTopResult: station.id == topStationID,
|
||||||
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableSortModes: [SortMode] {
|
private var availableSortModes: [SortMode] {
|
||||||
@@ -334,44 +420,160 @@ struct StationsView: View {
|
|||||||
return modes
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
}
|
||||||
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
|
||||||
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
/// The station list's real RAG dot, paired with the rating name. The range lives
|
||||||
HStack(spacing: 12) {
|
/// in the aligned description column rather than as a separate trailing value.
|
||||||
Image(systemName: icon)
|
private struct KeyPriceRatingIndicator: View {
|
||||||
.font(.title3)
|
let item: KeySheetContent.Item
|
||||||
.foregroundStyle(color)
|
let color: Color
|
||||||
.frame(width: 34)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
var body: some View {
|
||||||
Text(LocalizedStringKey(title)).font(.headline)
|
HStack(spacing: 5) {
|
||||||
Text(LocalizedStringKey(blurb))
|
PriceRatingDot(color: color)
|
||||||
.font(.caption)
|
Text(LocalizedStringKey(item.label))
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption.weight(.semibold))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 8)
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle().fill(color).frame(width: 10, height: 10)
|
|
||||||
Text(threshold)
|
|
||||||
.font(.headline.weight(.bold))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.keyCardFill(color: color)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card background shared by the Key sheet cards — subtle accent tint over
|
/// Every Key section shares this one two-column row. The scaled first-column
|
||||||
/// the grouped background, matching the tip-section card style.
|
/// width keeps every description on the same leading axis while preserving the
|
||||||
private extension View {
|
/// inline layout at accessibility sizes; descriptions own the flexible column
|
||||||
func keyCardFill(color: Color) -> some View {
|
/// and grow vertically without truncating.
|
||||||
background(
|
private struct KeySheetDescriptionRow<Indicator: View>: View {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
private let indicatorColumnWidth: CGFloat = 128
|
||||||
.fill(Color(.secondarySystemGroupedBackground))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
let item: KeySheetContent.Item
|
||||||
|
@ViewBuilder let indicator: Indicator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
indicator
|
||||||
|
.frame(width: indicatorColumnWidth, alignment: .leading)
|
||||||
|
description
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("\(item.label). \(item.description)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var description: some View {
|
||||||
|
Text(LocalizedStringKey(item.description))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the same listing capsule component as real station rows, with neutral
|
||||||
|
/// fixture numbers: only the verdict is visible in this compact variant.
|
||||||
|
private struct KeySavingsCapsule: View {
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
private var status: FuelSavingsStatus {
|
||||||
|
switch label {
|
||||||
|
case "Saves": return .worthTheDrive
|
||||||
|
case "Costs more": return .notWorthTheDrive
|
||||||
|
default: return .breakEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var result: FuelSavingsResult {
|
||||||
|
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
|
||||||
|
return FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: netSaving,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: netSaving,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// The legend demonstrates the wording, not a figure: `result` above is a
|
||||||
|
// placeholder verdict, and its £1.00 must never reach the screen.
|
||||||
|
ListingFuelSavingsCapsule(result: result, showsAmount: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared RAG dot used beside live station prices and in the Key.
|
||||||
|
struct PriceRatingDot: View {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared favourite glyph used by station rows and the Key.
|
||||||
|
struct FavouriteStarGlyph: View {
|
||||||
|
let isFavourite: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
||||||
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared station-label capsule. `StationRow` uses the same view for its live
|
||||||
|
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
|
||||||
|
struct StationLabelBadge: View {
|
||||||
|
let label: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(LocalizedStringKey(label))
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(color.opacity(0.15)))
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KeyStationLabelIndicator: View {
|
||||||
|
let label: String
|
||||||
|
let fuelColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch label {
|
||||||
|
case "BEST VALUE":
|
||||||
|
StationLabelBadge(label: label, color: .green)
|
||||||
|
case "CHEAPEST":
|
||||||
|
StationLabelBadge(label: label, color: .blue)
|
||||||
|
case "CLOSEST":
|
||||||
|
// The listing's own capsule, so the Key demonstrates the same soft
|
||||||
|
// blue fill and white text the listing shows. `FuelSavingsCapsule`
|
||||||
|
// remains the detail sheet's alone.
|
||||||
|
ListingFuelSavingsCapsule(result: baselineResult)
|
||||||
|
default:
|
||||||
|
Text("TOP")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(fuelColor.opacity(0.18), in: Capsule())
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baselineResult: FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import WatchConnectivity
|
|||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
|
/// Posted by the "How Best Value works" sheet's Setup Best Value button, so
|
||||||
|
/// the app can close that sheet's stack and land on Settings — whose Best
|
||||||
|
/// Value section is the first thing in its list. The sheet cannot switch
|
||||||
|
/// tabs itself: it is presented two levels below the tab view.
|
||||||
|
static let fuelBoardOpenBestValueSettings = Notification.Name("FuelBoardOpenBestValueSettings")
|
||||||
/// Posted when a road-distance fill finishes, so the app's rows re-read the
|
/// Posted when a road-distance fill finishes, so the app's rows re-read the
|
||||||
/// distance they are showing — those reads happen inside the row views, so
|
/// distance they are showing — those reads happen inside the row views, so
|
||||||
/// nothing else would invalidate them.
|
/// nothing else would invalidate them.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ struct WidgetMockScreen: View {
|
|||||||
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||||
// 5 miles — the widget config's default Distance. WidgetDistance itself
|
// 5 miles — the widget config's default Distance. WidgetDistance itself
|
||||||
// lives in the widget target, so the preview mirrors its default here.
|
// lives in the widget target, so the preview mirrors its default here.
|
||||||
let radiusKM = 5 * 1.60934
|
let radiusKM = 5 * FuelSavingsCalculator.kilometresPerMile
|
||||||
let all = FuelStore.loadStations()
|
let all = FuelStore.loadStations()
|
||||||
let selling = all.filter { station in
|
let selling = all.filter { station in
|
||||||
guard station.prices[fuel] != nil else { return false }
|
guard station.prices[fuel] != nil else { return false }
|
||||||
@@ -94,7 +94,8 @@ struct WidgetMockScreen: View {
|
|||||||
isFavourites: false,
|
isFavourites: false,
|
||||||
location: location,
|
location: location,
|
||||||
locationSource: location != nil ? "live" : "none",
|
locationSource: location != nil ? "live" : "none",
|
||||||
unit: FuelStore.loadDistanceUnit()
|
unit: FuelStore.loadDistanceUnit(),
|
||||||
|
bestValueResult: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"Settings" = "Settings";
|
"Settings" = "Settings";
|
||||||
"FuelBoard" = "FuelBoard";
|
"FuelBoard" = "FuelBoard";
|
||||||
"TOP" = "TOP";
|
"TOP" = "TOP";
|
||||||
|
"BEST VALUE" = "BEST VALUE";
|
||||||
|
"CHEAPEST" = "CHEAPEST";
|
||||||
|
|
||||||
/* Stations tab */
|
/* Stations tab */
|
||||||
"Closest %@ stations — nearest first, best value within %lld %@ · %lld stations" = "Closest %@ stations — nearest first, best value within %lld %@ · %lld stations";
|
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
||||||
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
|
"Cheapest %@ within %lld %@ — %lld stations · tap a station for directions." = "Cheapest %@ within %lld %@ — %lld stations · tap a station for directions.";
|
||||||
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
|
"%@ %@ — tap a station for directions." = "%@ %@ — tap a station for directions.";
|
||||||
"Fuel type" = "Fuel type";
|
"Fuel type" = "Fuel type";
|
||||||
@@ -23,18 +25,43 @@
|
|||||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||||
"All %lld stations shown" = "All %lld stations shown";
|
"All %lld stations shown" = "All %lld stations shown";
|
||||||
"Key" = "Key";
|
"Key" = "Key";
|
||||||
"Best value" = "Best value";
|
"Price ratings" = "Price ratings";
|
||||||
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
"Good price" = "Good price";
|
||||||
|
"Within 1.5p/L of the cheapest." = "Within 1.5p/L of the cheapest.";
|
||||||
"Okay" = "Okay";
|
"Okay" = "Okay";
|
||||||
"Within 4p of the cheapest" = "Within 4p of the cheapest";
|
"More than 1.5p/L, up to 4p/L above the cheapest." = "More than 1.5p/L, up to 4p/L above the cheapest.";
|
||||||
"Pricey" = "Pricey";
|
"Pricey" = "Pricey";
|
||||||
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
"More than 4p/L above the cheapest." = "More than 4p/L above the cheapest.";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Savings" = "Savings";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Saves" = "Saves";
|
||||||
|
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
|
||||||
|
"Break even" = "Break even";
|
||||||
|
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
|
||||||
|
"Costs more" = "Costs more";
|
||||||
|
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
|
||||||
|
/* Station listing verdict capsule: the words above the figure */
|
||||||
|
"Costs" = "Costs";
|
||||||
|
"(approx.)" = "(approx.)";
|
||||||
|
"%@ more" = "%@ more";
|
||||||
|
"Station labels" = "Station labels";
|
||||||
|
"CLOSEST" = "CLOSEST";
|
||||||
|
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
|
||||||
|
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
|
||||||
|
"The nearest station by journey distance." = "The nearest station by journey distance.";
|
||||||
|
"The cheapest station in your Favourites for the selected fuel." = "The cheapest station in your Favourites for the selected fuel.";
|
||||||
|
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
|
||||||
|
"How does Best Value work?" = "How does Best Value work?";
|
||||||
/* Star button in the More info sheet heading (accessibility labels) */
|
/* Star button in the More info sheet heading (accessibility labels) */
|
||||||
"Add to favourites" = "Add to favourites";
|
"Add to favourites" = "Add to favourites";
|
||||||
"Remove from favourites" = "Remove from favourites";
|
"Remove from favourites" = "Remove from favourites";
|
||||||
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
||||||
|
"How Best Value works" = "How Best Value works";
|
||||||
|
"Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
||||||
|
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.";
|
||||||
|
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
||||||
|
"Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs." = "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.";
|
||||||
|
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
||||||
|
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
||||||
|
|
||||||
/* Favourites tab */
|
/* Favourites tab */
|
||||||
"No favourites yet" = "No favourites yet";
|
"No favourites yet" = "No favourites yet";
|
||||||
@@ -49,35 +76,44 @@
|
|||||||
"Radius" = "Radius";
|
"Radius" = "Radius";
|
||||||
"%lld %@" = "%lld %@";
|
"%lld %@" = "%lld %@";
|
||||||
"Follow search (%@)" = "Follow search (%@)";
|
"Follow search (%@)" = "Follow search (%@)";
|
||||||
"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." = "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.";
|
|
||||||
"No stations monitored yet — open the Stations tab to load prices first." = "No stations monitored yet — open the Stations tab to load prices first.";
|
"No stations monitored yet — open the Stations tab to load prices first." = "No stations monitored yet — open the Stations tab to load prices first.";
|
||||||
"Last alert" = "Last alert";
|
"Last alert" = "Last alert";
|
||||||
"Live Activity" = "Live Activity";
|
"Live Activity" = "Live Activity";
|
||||||
"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." = "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.";
|
|
||||||
|
|
||||||
/* Settings tab */
|
/* Settings tab */
|
||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Choose how distances are displayed throughout FuelBoard." = "Choose how distances are displayed throughout FuelBoard.";
|
||||||
|
"Show prices as 129.9p or £1.299 per litre." = "Show prices as 129.9p or £1.299 per litre.";
|
||||||
"Tap station" = "Tap station";
|
"Tap station" = "Tap station";
|
||||||
"Tap action" = "Tap action";
|
"Tap action" = "Tap action";
|
||||||
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
"Choose what happens when you tap a station: get directions or view its details." = "Choose what happens when you tap a station: get directions or view its details.";
|
||||||
"Open map" = "Open map";
|
"Open map" = "Open map";
|
||||||
"More info" = "More info";
|
"More info" = "More info";
|
||||||
"Name" = "Name";
|
"Name" = "Name";
|
||||||
"Address" = "Address";
|
"Address" = "Address";
|
||||||
"Distance" = "Distance";
|
|
||||||
"Copied" = "Copied";
|
|
||||||
"Copies the postcode" = "Copies the postcode";
|
"Copies the postcode" = "Copies the postcode";
|
||||||
|
"Postcode copied" = "Postcode copied";
|
||||||
"Address: %@" = "Address: %@";
|
"Address: %@" = "Address: %@";
|
||||||
"Directions to %@" = "Directions to %@";
|
"Directions to %@" = "Directions to %@";
|
||||||
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"View the welcome screens and set up permissions again." = "View the welcome screens and set up permissions again.";
|
||||||
|
|
||||||
|
/* Settings — refined copy (Fuel Savings, Distances, Data checking) */
|
||||||
|
"Fill-up amount" = "Fill-up amount";
|
||||||
|
"Find stations that could save you money, even after the cost of driving there." = "Find stations that could save you money, even after the cost of driving there.";
|
||||||
|
"How many miles your car travels per gallon." = "How many miles your car travels per gallon.";
|
||||||
|
"How much you plan to spend on fuel." = "How much you plan to spend on fuel.";
|
||||||
|
"How many litres you usually buy." = "How many litres you usually buy.";
|
||||||
|
"The smallest saving you'd consider worth the extra drive." = "The smallest saving you'd consider worth the extra drive.";
|
||||||
|
"FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance." = "FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.";
|
||||||
|
"Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open." = "Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.";
|
||||||
|
"Background updates depend on when your iPhone allows them." = "Background updates depend on when your iPhone allows them.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
"Send plain test notification" = "Send plain test notification";
|
"Send plain test notification" = "Send plain test notification";
|
||||||
"Debug" = "Debug";
|
"Debug" = "Debug";
|
||||||
"The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.";
|
"The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius." = "The first button applies the real criteria — cheapest %@ station within %@ of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.";
|
||||||
"Widget Diagnostics" = "Widget Diagnostics";
|
"Widget Diagnostics" = "Widget Diagnostics";
|
||||||
"Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter." = "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.";
|
"Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter." = "Each beacon is written by the widget extension at the end of every timeline entry for that intent type (keychain, so it survives free-SideStore installs). A missing SMALL beacon after adding the small widget means its timeline never reaches the provider. Installed widgets come from WidgetCenter.";
|
||||||
"SMALL: %@" = "SMALL: %@";
|
"SMALL: %@" = "SMALL: %@";
|
||||||
@@ -135,7 +171,7 @@
|
|||||||
"Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.";
|
"Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day." = "Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.";
|
||||||
"Prices near you" = "Prices near you";
|
"Prices near you" = "Prices near you";
|
||||||
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
|
"FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device." = "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.";
|
||||||
"Location was denied. You can still browse all stations, but \"nearest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.";
|
"Location was denied. You can still browse all stations, but \"closest\" sorting needs it." = "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.";
|
||||||
"Allowed" = "Allowed";
|
"Allowed" = "Allowed";
|
||||||
"The system prompt will appear next." = "The system prompt will appear next.";
|
"The system prompt will appear next." = "The system prompt will appear next.";
|
||||||
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
|
"FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.";
|
||||||
@@ -194,21 +230,60 @@
|
|||||||
/* Alerts and favourites localisation pass */
|
/* Alerts and favourites localisation pass */
|
||||||
"Cheapest favourite" = "Cheapest favourite";
|
"Cheapest favourite" = "Cheapest favourite";
|
||||||
"Check now" = "Check now";
|
"Check now" = "Check now";
|
||||||
"Price-drop alerts" = "Price-drop alerts";
|
|
||||||
"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." = "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.";
|
|
||||||
"Cheapest station" = "Cheapest station";
|
"Cheapest station" = "Cheapest station";
|
||||||
"Alert type" = "Alert type";
|
"Alert type" = "Alert type";
|
||||||
"Best Value" = "Best Value";
|
"Best Value" = "Best Value";
|
||||||
|
"Total" = "Total";
|
||||||
"Minimum saving" = "Minimum saving";
|
"Minimum saving" = "Minimum saving";
|
||||||
|
"Minimum saving %@ — change in Settings, Fuel Savings." = "Minimum saving %@ — change in Settings, Fuel Savings.";
|
||||||
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
|
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
|
||||||
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
|
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
|
||||||
"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." = "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.";
|
|
||||||
"Proximity alerts" = "Proximity alerts";
|
"Proximity alerts" = "Proximity alerts";
|
||||||
"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." = "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.";
|
|
||||||
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";
|
/* Best Value calculation and station price age */
|
||||||
"Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips." = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.";
|
"How much fuel?" = "How much fuel?";
|
||||||
"City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town." = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.";
|
"Adjust your fill-up amount for this visit. Your default won’t change." = "Adjust your fill-up amount for this visit. Your default won’t change.";
|
||||||
"Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns." = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.";
|
"About comparison modes" = "About comparison modes";
|
||||||
"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." = "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.";
|
"Comparison modes" = "Comparison modes";
|
||||||
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
|
"£: Compare how much fuel your budget buys at each station." = "£: Compare how much fuel your budget buys at each station.";
|
||||||
"%@ — %@" = "%@ — %@";
|
"Litres: Compare the cost of buying the same amount of fuel." = "Litres: Compare the cost of buying the same amount of fuel.";
|
||||||
|
"You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.";
|
||||||
|
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
||||||
|
"your order" = "your order";
|
||||||
|
"Price updated" = "Price updated";
|
||||||
|
"%@ ago" = "%@ ago";
|
||||||
|
"Not provided" = "Not provided";
|
||||||
|
"May be outdated" = "May be outdated";
|
||||||
|
"Fuel you'll receive" = "Fuel you'll receive";
|
||||||
|
"Compared with your closest station." = "Compared with your closest station.";
|
||||||
|
|
||||||
|
/* Alerts tab — refined copy (sections, descriptions, limitations) */
|
||||||
|
"Favourite price alerts" = "Favourite price alerts";
|
||||||
|
"Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price." = "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.";
|
||||||
|
"Get notified when you're near one of the cheapest stations for your chosen fuel." = "Get notified when you're near one of the cheapest stations for your chosen fuel.";
|
||||||
|
"Get notified when you're near a station that could save you money after travel costs." = "Get notified when you're near a station that could save you money after travel costs.";
|
||||||
|
"Get alerts when you're within %@ of a matching station." = "Get alerts when you're within %@ of a matching station.";
|
||||||
|
"Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts)." = "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).";
|
||||||
|
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
||||||
|
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
||||||
|
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
||||||
|
|
||||||
|
/* Important information — the shared disclaimer sheet (one card per heading) */
|
||||||
|
"Important information" = "Important information";
|
||||||
|
"Fuel Prices" = "Fuel Prices";
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app." = "FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.";
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump." = "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump.";
|
||||||
|
"Fuel Availability" = "Fuel Availability";
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type." = "A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.";
|
||||||
|
"We recommend checking availability before making a special journey." = "We recommend checking availability before making a special journey.";
|
||||||
|
"Savings & Best Value" = "Savings & Best Value";
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings." = "Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.";
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost." = "Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost.";
|
||||||
|
"Journey Estimates" = "Journey Estimates";
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used." = "Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.";
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits." = "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits.";
|
||||||
|
"Before You Travel" = "Before You Travel";
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." = "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up.";
|
||||||
|
"Disclaimer" = "Disclaimer";
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations." = "FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.";
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided." = "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided.";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/FuelPriceFreshness.swift
|
||||||
@@ -211,10 +211,10 @@ final class DistanceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testMilesConversion() {
|
func testMilesConversion() {
|
||||||
// 5 miles ≈ 8.05 km (app uses × 1.60934)
|
// 5 miles ≈ 8.05 km using the shared exact conversion
|
||||||
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
|
XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
|
||||||
// 262 km ≈ 162.8 miles (display uses × 0.621371)
|
// 262 km ≈ 162.8 miles using the shared exact conversion
|
||||||
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1)
|
XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,14 +267,14 @@ final class FuelTypeLabelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testDistanceUnitConversion() {
|
func testDistanceUnitConversion() {
|
||||||
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001)
|
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.609344, accuracy: 0.00001)
|
||||||
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
|
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
|
||||||
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001)
|
XCTAssertEqual(DistanceUnit.miles.fromKM(1.609344), 1, accuracy: 0.00001)
|
||||||
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
|
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDistanceUnitFormat() {
|
func testDistanceUnitFormat() {
|
||||||
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi")
|
XCTAssertEqual(DistanceUnit.miles.format(1.609344), "1.0 mi")
|
||||||
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
|
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +357,68 @@ final class AlertsFuelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Settings sliders save ONE field per gesture instead of rewriting all four
|
||||||
|
/// keys (four UserDefaults writes plus four keychain round trips per drag is what
|
||||||
|
/// made them stutter). These pin the store half of that: a single-field save must
|
||||||
|
/// leave the other three keys exactly as they were.
|
||||||
|
final class FuelSavingsFieldSaveTests: XCTestCase {
|
||||||
|
private func seed() {
|
||||||
|
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 40,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingMPGLeavesTheOtherFieldsAlone() {
|
||||||
|
seed()
|
||||||
|
var edited = FuelStore.loadFuelSavingsSettings()
|
||||||
|
edited.mpg = 62
|
||||||
|
FuelStore.saveFuelSavingsField(.mpg, from: edited)
|
||||||
|
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||||
|
XCTAssertEqual(reloaded.mpg, 62, "the edited field is written")
|
||||||
|
XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save")
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save")
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingFillAmountLeavesMPGAndUnitAlone() {
|
||||||
|
seed()
|
||||||
|
var edited = FuelStore.loadFuelSavingsSettings()
|
||||||
|
edited.preferredAmount.value = 75
|
||||||
|
FuelStore.saveFuelSavingsField(.amount, from: edited)
|
||||||
|
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written")
|
||||||
|
XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save")
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingEnabledLeavesTheNumbersAlone() {
|
||||||
|
seed()
|
||||||
|
var edited = FuelStore.loadFuelSavingsSettings()
|
||||||
|
edited.enabled = false
|
||||||
|
FuelStore.saveFuelSavingsField(.enabled, from: edited)
|
||||||
|
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||||
|
XCTAssertFalse(reloaded.enabled, "the edited field is written")
|
||||||
|
XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save")
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWholeSettingsSaveStillWritesEveryField() {
|
||||||
|
seed()
|
||||||
|
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
|
||||||
|
enabled: false,
|
||||||
|
mpg: 55,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres)
|
||||||
|
))
|
||||||
|
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||||
|
XCTAssertFalse(reloaded.enabled)
|
||||||
|
XCTAssertEqual(reloaded.mpg, 55)
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.value, 30)
|
||||||
|
XCTAssertEqual(reloaded.preferredAmount.unit, .litres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class AlertRadiusFollowTests: XCTestCase {
|
final class AlertRadiusFollowTests: XCTestCase {
|
||||||
func testAlertRadiusOptionsAreMileFriendly() {
|
func testAlertRadiusOptionsAreMileFriendly() {
|
||||||
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
|
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
|
||||||
@@ -400,7 +462,7 @@ final class AlertRadiusFollowTests: XCTestCase {
|
|||||||
FuelStore.saveDistanceUnit(.miles)
|
FuelStore.saveDistanceUnit(.miles)
|
||||||
FuelStore.saveStationLimit(5)
|
FuelStore.saveStationLimit(5)
|
||||||
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
|
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
|
||||||
XCTAssertEqual(eff, 5 * 1.60934, accuracy: 0.001)
|
XCTAssertEqual(eff, 5 * FuelSavingsCalculator.kilometresPerMile, accuracy: 0.001)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,7 +793,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
sourceLat: sourceLat, sourceLng: sourceLng,
|
sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
|
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
|
||||||
|
|
||||||
let tenMilesKM = 10 * 1.60934
|
let tenMilesKM = 10 * FuelSavingsCalculator.kilometresPerMile
|
||||||
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
|
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
|
||||||
"fixture: the straight line must sit INSIDE the 10-mile radius")
|
"fixture: the straight line must sit INSIDE the 10-mile radius")
|
||||||
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
|
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
|
||||||
@@ -743,7 +805,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
userLat: sourceLat, userLng: sourceLng),
|
userLat: sourceLat, userLng: sourceLng),
|
||||||
"a station whose row reads 14.6 mi must not appear in a 10-mile list")
|
"a station whose row reads 14.6 mi must not appear in a 10-mile list")
|
||||||
// The same station is back once the radius actually covers its distance.
|
// The same station is back once the radius actually covers its distance.
|
||||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * 1.60934,
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * FuelSavingsCalculator.kilometresPerMile,
|
||||||
userLat: sourceLat, userLng: sourceLng))
|
userLat: sourceLat, userLng: sourceLng))
|
||||||
// …and an unknown station (no cached route) still tests its displayed
|
// …and an unknown station (no cached route) still tests its displayed
|
||||||
// straight line, so nothing changes for the bulk of the list.
|
// straight line, so nothing changes for the bulk of the list.
|
||||||
@@ -783,10 +845,16 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
|
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
|
||||||
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
|
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
|
||||||
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
|
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
|
||||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.0 mi return)")
|
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
|
||||||
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)")
|
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
|
||||||
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
|
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
|
||||||
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.0 miles return")
|
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
|
||||||
|
// The return figure split out on its own — a surface that prints the
|
||||||
|
// one-way and the return distance on separate lines still gets BOTH from
|
||||||
|
// this type, marker included.
|
||||||
|
XCTAssertEqual(confirmed.returnLabel(unit: .miles), "4.2 mi return")
|
||||||
|
XCTAssertEqual(confirmed.returnLabel(unit: .kilometers), "6.8 km return")
|
||||||
|
XCTAssertEqual(confirmed.spokenReturnLabel(unit: .miles), "4.2 miles return")
|
||||||
|
|
||||||
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
|
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
|
||||||
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
|
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
|
||||||
@@ -794,11 +862,44 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
XCTAssertEqual(approximate.label(unit: .miles),
|
XCTAssertEqual(approximate.label(unit: .miles),
|
||||||
"Approx. " + DistanceUnit.miles.format(approximate.km))
|
"Approx. " + DistanceUnit.miles.format(approximate.km))
|
||||||
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
|
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
|
||||||
"Approx. \(DistanceUnit.miles.format(approximate.km)) (\(DistanceUnit.miles.format(approximate.km * 2)) return)")
|
"Approx. " + DistanceUnit.miles.format(approximate.km) + " (Approx. " + DistanceUnit.miles.format(approximate.km * 2) + " return)")
|
||||||
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
|
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
|
||||||
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
|
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
|
||||||
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
|
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
|
||||||
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
|
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), Approximately \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayedReturnUsesTheIndependentlyMeasuredReturnRoute() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let station = station("separate-legs", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: 51.6,
|
||||||
|
sourceLng: -0.1,
|
||||||
|
entries: ["separate-legs": .init(
|
||||||
|
meters: 4_650,
|
||||||
|
lat: station.lat,
|
||||||
|
lng: station.lng,
|
||||||
|
returnMeters: 4_820
|
||||||
|
)],
|
||||||
|
complete: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let display = FuelStore.stationDistanceDisplay(
|
||||||
|
station: station,
|
||||||
|
userLat: 51.6,
|
||||||
|
userLng: -0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(display.km, 4.65, accuracy: 1e-12)
|
||||||
|
XCTAssertEqual(display.returnKM, 9.47, accuracy: 1e-12)
|
||||||
|
XCTAssertFalse(display.isApproximate)
|
||||||
|
XCTAssertFalse(display.returnIsApproximate)
|
||||||
|
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
|
||||||
|
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
|
||||||
|
// The split-out return figure must use the INDEPENDENT return route
|
||||||
|
// (9.47 km), not a doubled one-way (2 × 4.65 = 9.30 km → 5.8 mi).
|
||||||
|
XCTAssertEqual(display.returnLabel(unit: .miles), "5.9 mi return")
|
||||||
|
XCTAssertEqual(display.spokenReturnLabel(unit: .miles), "5.9 miles return")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The marker tracks the cache on its own: same station, bare figure once its
|
/// The marker tracks the cache on its own: same station, bare figure once its
|
||||||
@@ -961,7 +1062,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
|
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
|
||||||
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
|
||||||
userLat: 51.6, userLng: -0.1),
|
userLat: 51.6, userLng: -0.1),
|
||||||
"with the mode off the radius is a straight-line radius, agreeing with the rows")
|
"with the mode off the radius is a straight-line radius, agreeing with the rows")
|
||||||
|
|
||||||
@@ -1000,7 +1101,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
XCTAssertFalse(display.isApproximate)
|
XCTAssertFalse(display.isApproximate)
|
||||||
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
|
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
|
||||||
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
|
||||||
userLat: 51.6, userLng: -0.1),
|
userLat: 51.6, userLng: -0.1),
|
||||||
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
|
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Station-level price age — one timestamp per station, reported in More info
|
||||||
|
/// and nowhere else.
|
||||||
|
///
|
||||||
|
/// The relay's `price_updated` is the newest `price_last_updated` across every
|
||||||
|
/// grade a station maps, so it is not evidence about the selected grade. These
|
||||||
|
/// tests pin what the app *reports*; `StationPriceAgePolicyTests` pins what the
|
||||||
|
/// app does about it (nothing).
|
||||||
|
final class FuelPriceFreshnessTests: XCTestCase {
|
||||||
|
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
|
||||||
|
private func at(_ offset: TimeInterval) -> TimeInterval {
|
||||||
|
now.addingTimeInterval(offset).timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(priceUpdated: TimeInterval?) -> FuelStation {
|
||||||
|
FuelStation(id: "freshness-station", name: "Freshness Station", brand: "Test",
|
||||||
|
address: "1 High Street", postcode: "BD1 1AA", lat: 53.79, lng: -1.75,
|
||||||
|
prices: [.e10: 169.9], priceUpdated: priceUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - One figure per station, whatever fuel is on screen
|
||||||
|
|
||||||
|
func testOneAgeIsReportedForEveryFuelAStationSells() {
|
||||||
|
let sold = FuelStation(id: "multi", name: "Multi", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.e5: 165.9, .e10: 169.9, .diesel: 129.9],
|
||||||
|
priceUpdated: at(-28 * 60 * 60))
|
||||||
|
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(for: sold, now: now).updateAgeLabel,
|
||||||
|
"28h ago",
|
||||||
|
"\(fuel.rawValue): every grade reads the same station timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationTimestampIsDecodedAndNoPerGradeTimestampIsInvented() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"stations": [{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "LISTER ARMS",
|
||||||
|
"brand": "Shell",
|
||||||
|
"address": "MANCHESTER ROAD, BRADFORD",
|
||||||
|
"postcode": "BD5 7JX",
|
||||||
|
"lat": 53.78,
|
||||||
|
"lng": -1.75,
|
||||||
|
"prices": {"E10": 169.9, "DIESEL": 129.9},
|
||||||
|
"price_updated": "2026-10-14T07:45:52Z",
|
||||||
|
"price_updated_by_grade": {
|
||||||
|
"E10": "2026-01-01T00:00:00Z",
|
||||||
|
"DIESEL": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).first)
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.priceUpdated,
|
||||||
|
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970,
|
||||||
|
"the station-level field is what the app records")
|
||||||
|
// A per-grade map in the payload is ignored: the app has one age, and it
|
||||||
|
// is the station's.
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: decoded, now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .outdated,
|
||||||
|
"the station field is used, not the 2026-01-01 grade entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading the age
|
||||||
|
|
||||||
|
func testFreshAgeReadsInMinutesThenHours() {
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-59 * 60), now: now).updateAgeLabel,
|
||||||
|
"59m ago")
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-60 * 60), now: now).updateAgeLabel,
|
||||||
|
"1h ago")
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-47 * 60 * 60), now: now).updateAgeLabel,
|
||||||
|
"47h ago")
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-48 * 60 * 60), now: now).updateAgeLabel,
|
||||||
|
"2d ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFreshInformationIsNeverFlagged() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: at(-28 * 60 * 60)), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
XCTAssertFalse(assessment.isOutdated)
|
||||||
|
XCTAssertNil(assessment.outdatedNotice)
|
||||||
|
XCTAssertEqual(assessment.age, 28 * 60 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The thirty-day display threshold
|
||||||
|
|
||||||
|
func testInformationExactlyThirtyDaysOldIsNotYetFlagged() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
XCTAssertNil(assessment.outdatedNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInformationJustPastThirtyDaysIsFlaggedButStillReportsItsAge() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge - 1), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .outdated)
|
||||||
|
XCTAssertTrue(assessment.isOutdated)
|
||||||
|
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "30d ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLongOldInformationIsFlaggedWithItsDayCount() {
|
||||||
|
// The oldest figure the mirror carries for a real station is ~250 days.
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-250 * 86_400), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .outdated)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "250d ago")
|
||||||
|
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Missing, malformed, future and absurd timestamps
|
||||||
|
|
||||||
|
func testMissingTimestampReadsNotProvidedAndIsNeverFlagged() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: nil), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .unknown)
|
||||||
|
XCTAssertNil(assessment.age)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
|
||||||
|
XCTAssertFalse(assessment.isOutdated)
|
||||||
|
XCTAssertNil(assessment.outdatedNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnparseableSourceTextIsDroppedRatherThanGuessed() throws {
|
||||||
|
XCTAssertNil(FuelPriceTimestamp.parse("not-a-date"))
|
||||||
|
XCTAssertNil(FuelPriceTimestamp.parse(""))
|
||||||
|
XCTAssertNil(FuelPriceTimestamp.parse(" "))
|
||||||
|
|
||||||
|
let json = """
|
||||||
|
{"stations": [{"id": "bad", "name": "Bad", "brand": "", "address": "", "postcode": "",
|
||||||
|
"lat": 53, "lng": -1, "prices": {"E10": 169.9}, "price_updated": "2026-99-99T99:99:99Z"}]}
|
||||||
|
"""
|
||||||
|
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).first)
|
||||||
|
XCTAssertNil(decoded.priceUpdated)
|
||||||
|
XCTAssertEqual(FuelPriceFreshness.assessment(for: decoded, now: now).updateAgeLabel, "Not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmallClockSkewReadsAsJustNowNotAsAFutureDate() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(60), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .fresh)
|
||||||
|
XCTAssertEqual(assessment.age, 0)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "0m ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimestampBeyondTheSkewToleranceIsUnusableNotOptimistic() {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(86_400), now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .invalid)
|
||||||
|
XCTAssertNil(assessment.age)
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
|
||||||
|
XCTAssertNil(assessment.outdatedNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonFiniteAndAbsurdEpochsAreUnusableAndCannotTrap() {
|
||||||
|
let values: [TimeInterval] = [.nan, .infinity, -.infinity,
|
||||||
|
.greatestFiniteMagnitude, -.greatestFiniteMagnitude]
|
||||||
|
for value in values {
|
||||||
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: value, now: now)
|
||||||
|
XCTAssertEqual(assessment.status, .invalid, "\(value)")
|
||||||
|
XCTAssertNil(assessment.age, "\(value)")
|
||||||
|
XCTAssertEqual(assessment.updateAgeLabel, "Not provided", "\(value)")
|
||||||
|
XCTAssertFalse(assessment.isOutdated, "\(value)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ISO8601DateFormatter {
|
||||||
|
static func fuelBoardDate(_ string: String) -> Date? {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter.date(from: string) ?? {
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: string)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,42 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - 1. Unit conversion
|
// MARK: - 1. Unit conversion
|
||||||
|
|
||||||
|
func testBudgetExamplesAcrossAllFuelTypes() throws {
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
|
||||||
|
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
|
||||||
|
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
|
for budget in [20.0, 40, 60, 73, 100] {
|
||||||
|
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
|
||||||
|
settings: settings, amount: .init(value: budget, unit: .pounds),
|
||||||
|
journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
let volume = budget / 1.299
|
||||||
|
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
|
||||||
|
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
|
||||||
|
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||||||
|
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||||
|
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||||
|
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||||
|
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||||
|
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||||
|
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testImperialGallonIsUsedNotTheUSGallon() {
|
func testImperialGallonIsUsedNotTheUSGallon() {
|
||||||
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
||||||
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
||||||
@@ -82,18 +118,84 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
|
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 2/4. The three stations from the brief
|
// MARK: - 2. Candidate-priced fill-up
|
||||||
|
|
||||||
|
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 169.9,
|
||||||
|
baselineJourneyMiles: 0.879,
|
||||||
|
candidateJourneyMiles: 8.306
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
|
||||||
|
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.headline(), "Costs £1.21 more")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
|
||||||
|
for (budget, expectedLitres, expectedGross) in [
|
||||||
|
(30.0, 17.657445556209535, 0.3531489111241907),
|
||||||
|
(60.0, 35.31489111241907, 0.7062978222483814),
|
||||||
|
(100.0, 58.85815185403179, 1.1771630370806357)
|
||||||
|
] {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 169.9,
|
||||||
|
baselineJourneyMiles: 0,
|
||||||
|
candidateJourneyMiles: 0
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
|
||||||
|
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
let first = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30, amount: amount,
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 169.9,
|
||||||
|
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||||
|
)
|
||||||
|
let differentBaseline = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30, amount: amount,
|
||||||
|
baselinePricePencePerLitre: 179.9,
|
||||||
|
candidatePricePencePerLitre: 169.9,
|
||||||
|
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||||
|
)
|
||||||
|
let differentCandidate = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30, amount: amount,
|
||||||
|
baselinePricePencePerLitre: 179.9,
|
||||||
|
candidatePricePencePerLitre: 159.9,
|
||||||
|
baselineJourneyMiles: 0, candidateJourneyMiles: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
|
||||||
|
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
|
||||||
|
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3/5. The three stations from the brief
|
||||||
|
|
||||||
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
|
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
|
||||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
|
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
|
||||||
|
|
||||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
|
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
|
||||||
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
|
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
|
XCTAssertEqual(result.headline(), "Costs £0.17 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The brief recorded "£0.18 worse off" for this row against a displayed
|
/// The brief recorded "£0.18 worse off" for this row against a displayed
|
||||||
@@ -104,9 +206,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
/// is entirely the rounding of the distance, not of the money.
|
/// is entirely the rounding of the distance, not of the money.
|
||||||
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
|
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
|
||||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
|
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
|
||||||
"£0.17 worse off after travel")
|
"Costs £0.17 more")
|
||||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
||||||
"£0.18 worse off after travel")
|
"Costs £0.18 more")
|
||||||
|
|
||||||
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
|
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
|
||||||
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
|
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
|
||||||
@@ -118,13 +220,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
|
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
|
||||||
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||||
|
|
||||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
|
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
|
||||||
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
|
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
|
XCTAssertEqual(result.headline(), "Costs £1.08 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
||||||
@@ -135,7 +237,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
|
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
|
||||||
"with no price difference the whole result is the extra driving")
|
"with no price difference the whole result is the extra driving")
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
|
XCTAssertEqual(result.headline(), "Costs £0.73 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 5/6. Classification, tolerance and rounding
|
// MARK: - 5/6. Classification, tolerance and rounding
|
||||||
@@ -150,15 +252,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
|
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
|
||||||
// 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305.
|
// 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance.
|
||||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
|
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
|
||||||
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9)
|
||||||
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
|
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
|
||||||
XCTAssertEqual(result.headline(), "Break even")
|
XCTAssertEqual(result.headline(), "Break even")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
|
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
|
||||||
let net = -0.168911
|
let net = -0.166869
|
||||||
// A tolerance wider than the loss → break even; narrower → worse off.
|
// A tolerance wider than the loss → break even; narrower → worse off.
|
||||||
let lenient = try FuelSavingsCalculator.calculate(
|
let lenient = try FuelSavingsCalculator.calculate(
|
||||||
mpg: 30,
|
mpg: 30,
|
||||||
@@ -187,7 +289,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
func testMoneyIsRoundedOnlyForDisplay() throws {
|
func testMoneyIsRoundedOnlyForDisplay() throws {
|
||||||
// Full precision retained internally…
|
// Full precision retained internally…
|
||||||
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||||
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
|
||||||
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
|
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
|
||||||
// …and rounded to two places only when shown.
|
// …and rounded to two places only when shown.
|
||||||
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
|
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
|
||||||
@@ -197,6 +299,23 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
|
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testIndependentlyRoundedFinancialComponentsMayDifferFromRoundedTotalByOnePenny() {
|
||||||
|
let gross = 1.234
|
||||||
|
let drivingCost = 0.005
|
||||||
|
let net = gross - drivingCost
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(gross), "£1.23")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(drivingCost), "£0.01")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(net), "£1.23")
|
||||||
|
let independentlyRoundedSubtraction = 1.23 - 0.01
|
||||||
|
XCTAssertEqual(independentlyRoundedSubtraction, 1.22, accuracy: 1e-12)
|
||||||
|
XCTAssertNotEqual(
|
||||||
|
FuelSavingsFormat.money(independentlyRoundedSubtraction),
|
||||||
|
FuelSavingsFormat.money(net),
|
||||||
|
"the raw total remains authoritative; independently rounded components need not add back exactly"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
|
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
|
||||||
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
|
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
|
||||||
// figure: the sort must use the raw value, never the shown one.
|
// figure: the sort must use the raw value, never the shown one.
|
||||||
@@ -237,12 +356,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 2.0,
|
baselineJourneyMiles: 2.0,
|
||||||
candidateJourneyMiles: 7.0
|
candidateJourneyMiles: 7.0
|
||||||
)
|
)
|
||||||
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
|
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.status, .worthTheDrive)
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
|
XCTAssertEqual(result.headline(), "Save £1.31")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
||||||
@@ -270,9 +389,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving")
|
XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving")
|
||||||
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
|
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
|
||||||
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
|
XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6)
|
||||||
|
|
||||||
let fortyFive = try FuelSavingsCalculator.calculate(
|
let fortyFive = try FuelSavingsCalculator.calculate(
|
||||||
mpg: 45,
|
mpg: 45,
|
||||||
@@ -283,7 +402,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
|
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
|
||||||
XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6)
|
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
||||||
@@ -295,10 +414,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
|
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
|
||||||
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
|
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
|
||||||
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
|
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
|
||||||
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
|
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
|
||||||
|
|
||||||
let ninety = try FuelSavingsCalculator.calculate(
|
let ninety = try FuelSavingsCalculator.calculate(
|
||||||
mpg: 30,
|
mpg: 30,
|
||||||
@@ -308,9 +427,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
|
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
|
||||||
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
|
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
|
||||||
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
|
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
||||||
@@ -370,7 +489,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
journey: journey(2.8),
|
journey: journey(2.8),
|
||||||
baselineJourney: journey(0.8)
|
baselineJourney: journey(0.8)
|
||||||
)
|
)
|
||||||
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6)
|
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
||||||
@@ -393,7 +512,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
|
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
|
||||||
"one unmeasured leg makes the whole estimate an estimate")
|
"one unmeasured leg makes the whole estimate an estimate")
|
||||||
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
|
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
|
||||||
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
|
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
|
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
|
||||||
|
|||||||
@@ -18,6 +18,70 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
func testListingCapsulePutsTheFigureOnItsOwnLine() {
|
||||||
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: status == .notWorthTheDrive ? 0 : 12.50,
|
||||||
|
extraDrivingCostGBP: status == .notWorthTheDrive ? 123.45 : 0,
|
||||||
|
extraDistanceMiles: 2,
|
||||||
|
netSavingGBP: status == .notWorthTheDrive ? -123.45 : 12.50,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The figure takes its own line, so the capsule's shape is decided by the
|
||||||
|
// data rather than by how much width the price column hands it.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Saves", "£12.50"])
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Saves", "£12.50", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more", "(approx.)"])
|
||||||
|
|
||||||
|
// Break even carries no figure — ±£0.05 is noise — so it stays one line
|
||||||
|
// when confirmed and gains a second only for the estimate qualifier.
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
|
["Break even", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
||||||
|
|
||||||
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
|
XCTAssertEqual(
|
||||||
|
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
|
||||||
|
["Costs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceOver keeps a single line: a drawn break is a spoken pause.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
|
||||||
|
"Saves £12.50 (approx.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
||||||
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: status == .notWorthTheDrive ? 0 : 2.50,
|
||||||
|
extraDrivingCostGBP: status == .notWorthTheDrive ? 1.20 : 0,
|
||||||
|
extraDistanceMiles: 2,
|
||||||
|
netSavingGBP: status == .notWorthTheDrive ? -1.20 : 2.50,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleHeadline(), "Save £2.50 (approx.)")
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).detailCapsuleHeadline(), "Costs £1.20 more (approx.)")
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).detailCapsuleHeadline(), "Break even (approx.)")
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).detailCapsuleHeadline(), "Save £2.50")
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).detailCapsuleHeadline(), "Costs £1.20 more")
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).detailCapsuleHeadline(), "Break even")
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleSpokenHeadline(), "Save £2.50 (approximately)")
|
||||||
|
}
|
||||||
|
|
||||||
private func station(_ id: String,
|
private func station(_ id: String,
|
||||||
price: Double?,
|
price: Double?,
|
||||||
lat: Double = 51.5,
|
lat: Double = 51.5,
|
||||||
@@ -41,6 +105,42 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuel types are never mixed: a station that does not sell the selected
|
||||||
|
/// fuel cannot be its baseline, and cannot be compared with it.
|
||||||
|
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
|
||||||
|
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.e10: 199.9], priceUpdated: nil)
|
||||||
|
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||||
|
prices: [.diesel: 129.9], priceUpdated: nil)
|
||||||
|
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
|
||||||
|
self.straightLine(station.id == "petrol" ? 0.5 : 1)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(nearest?.id, "diesel")
|
||||||
|
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
|
||||||
|
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
|
||||||
|
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranking must still work when the list being ordered is filtered tighter
|
||||||
|
/// than the pool the baseline was chosen from.
|
||||||
|
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
|
||||||
|
let baseline = station("nearest-outside-radius", price: 199.9)
|
||||||
|
let cheap = station("z-cheap", price: 129.9)
|
||||||
|
let expensive = station("a-expensive", price: 198.9)
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
[expensive, cheap], fuel: .e10, baseline: baseline,
|
||||||
|
settings: settings, amount: amount,
|
||||||
|
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
|
||||||
|
)
|
||||||
|
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
|
||||||
|
let expected = FuelSavingsEstimator.availability(
|
||||||
|
station: cheap, fuel: .e10, baseline: baseline,
|
||||||
|
settings: settings, amount: amount,
|
||||||
|
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
|
||||||
|
)?.result
|
||||||
|
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||||||
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||||||
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||||||
@@ -284,9 +384,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||||||
///
|
///
|
||||||
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||||||
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
|
||||||
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
|
||||||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
|
||||||
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||||||
let baseline = station("rontec-shelf", price: 171.9)
|
let baseline = station("rontec-shelf", price: 171.9)
|
||||||
let candidate = station("shell-whetley-hill", price: 167.9)
|
let candidate = station("shell-whetley-hill", price: 167.9)
|
||||||
@@ -304,16 +404,16 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
guard let result = availability?.result else {
|
guard let result = availability?.result else {
|
||||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
}
|
}
|
||||||
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
|
||||||
"the £60 budget buys litres at the BASELINE pump price")
|
"the £60 budget buys litres at the candidate pump price")
|
||||||
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
|
||||||
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||||||
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||||||
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
XCTAssertEqual(result.journeyConfidence, .estimated)
|
XCTAssertEqual(result.journeyConfidence, .estimated)
|
||||||
XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel")
|
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||||||
@@ -332,8 +432,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||||||
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
|
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
|
||||||
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
|
||||||
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBaselineRowIsLabelledClosestStation() {
|
func testBaselineRowIsLabelledClosestStation() {
|
||||||
@@ -375,10 +475,10 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
}
|
}
|
||||||
XCTAssertEqual(result.status, .worthTheDrive)
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
|
||||||
// return miles cost ~£0.09. Net ≈ £3.66.
|
// return miles cost ~£0.09. Net ≈ £3.91.
|
||||||
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
|
||||||
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
|
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNeutralResultHeadlineIsBreakEven() {
|
func testNeutralResultHeadlineIsBreakEven() {
|
||||||
@@ -413,7 +513,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: 0.52,
|
netSavingGBP: 0.52,
|
||||||
status: .worthTheDrive,
|
status: .worthTheDrive,
|
||||||
distanceBasis: .roadRoute)
|
distanceBasis: .roadRoute)
|
||||||
XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
|
XCTAssertEqual(saving.headline(), "Save £0.52")
|
||||||
|
|
||||||
let loss = FuelSavingsResult(fillLitres: 34.9,
|
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||||||
grossSavingGBP: 1.40,
|
grossSavingGBP: 1.40,
|
||||||
@@ -422,7 +522,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: -0.31,
|
netSavingGBP: -0.31,
|
||||||
status: .notWorthTheDrive,
|
status: .notWorthTheDrive,
|
||||||
distanceBasis: .roadRoute)
|
distanceBasis: .roadRoute)
|
||||||
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
XCTAssertEqual(loss.headline(), "Costs £0.31 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sort must order by NET saving, not pump price: a pricier station can
|
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||||
@@ -454,6 +554,25 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: Row copy
|
// MARK: Row copy
|
||||||
|
|
||||||
|
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||||||
|
"vs closest station"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||||||
|
"vs closest station"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||||||
|
"vs closest station"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||||||
|
"your baseline"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
||||||
/// figure is always miles, and converting by the current unit instead would
|
/// figure is always miles, and converting by the current unit instead would
|
||||||
/// leave a km user's figure unlabelled and 1.6× too large.
|
/// leave a km user's figure unlabelled and 1.6× too large.
|
||||||
@@ -486,7 +605,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: 0,
|
netSavingGBP: 0,
|
||||||
status: .baseline
|
status: .baseline
|
||||||
)
|
)
|
||||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline")
|
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
|
||||||
|
|
||||||
let saving = FuelSavingsResult(
|
let saving = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -497,7 +616,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
status: .worthTheDrive,
|
status: .worthTheDrive,
|
||||||
distanceBasis: .straightLine
|
distanceBasis: .straightLine
|
||||||
)
|
)
|
||||||
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
|
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
|
||||||
|
|
||||||
let worse = FuelSavingsResult(
|
let worse = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -507,7 +626,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
netSavingGBP: -0.31,
|
netSavingGBP: -0.31,
|
||||||
status: .notWorthTheDrive
|
status: .notWorthTheDrive
|
||||||
)
|
)
|
||||||
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
|
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Estimate wording (Approx. labels)
|
// MARK: - Estimate wording (Approx. labels)
|
||||||
@@ -524,7 +643,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
status: .worthTheDrive,
|
status: .worthTheDrive,
|
||||||
distanceBasis: .straightLine
|
distanceBasis: .straightLine
|
||||||
)
|
)
|
||||||
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
|
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
|
||||||
|
|
||||||
let loss = FuelSavingsResult(
|
let loss = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -535,7 +654,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
status: .notWorthTheDrive,
|
status: .notWorthTheDrive,
|
||||||
distanceBasis: .straightLine
|
distanceBasis: .straightLine
|
||||||
)
|
)
|
||||||
XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel")
|
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A confirmed road-route comparison must never carry the marker — in either
|
/// A confirmed road-route comparison must never carry the marker — in either
|
||||||
@@ -551,7 +670,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
distanceBasis: .roadRoute,
|
distanceBasis: .roadRoute,
|
||||||
journeyConfidence: .confirmed
|
journeyConfidence: .confirmed
|
||||||
)
|
)
|
||||||
XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
|
XCTAssertEqual(saving.headline(), "Save £1.25")
|
||||||
|
|
||||||
let loss = FuelSavingsResult(
|
let loss = FuelSavingsResult(
|
||||||
fillLitres: 30,
|
fillLitres: 30,
|
||||||
@@ -563,7 +682,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
distanceBasis: .roadRoute,
|
distanceBasis: .roadRoute,
|
||||||
journeyConfidence: .confirmed
|
journeyConfidence: .confirmed
|
||||||
)
|
)
|
||||||
XCTAssertEqual(loss.headline(), "£0.77 worse off after travel")
|
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
|
||||||
|
|
||||||
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
|
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
|
||||||
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
|
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
|
||||||
@@ -585,6 +704,29 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same figure on its own, for the sheet's row form ("Extra return
|
||||||
|
/// travel | 6.4 mi"): identical value and identical marker rule, so the row
|
||||||
|
/// and the caption can never disagree about being an estimate. Below the
|
||||||
|
/// 0.05-mile floor both forms return nil together.
|
||||||
|
func testExtraTravelDistanceLabelMatchesTheCaption() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles),
|
||||||
|
"6.4 mi"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||||||
|
"Approx. 6.4 mi"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .kilometers),
|
||||||
|
"10.3 km"
|
||||||
|
)
|
||||||
|
XCTAssertNil(
|
||||||
|
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 0.04, unit: .miles),
|
||||||
|
"below the floor there is no extra travel to name"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
|
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
|
||||||
/// "mi"/"km" → "miles"/"kilometres".
|
/// "mi"/"km" → "miles"/"kilometres".
|
||||||
func testSpokenLabelsSpellOutTheAbbreviations() {
|
func testSpokenLabelsSpellOutTheAbbreviations() {
|
||||||
@@ -597,7 +739,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
status: .notWorthTheDrive,
|
status: .notWorthTheDrive,
|
||||||
distanceBasis: .straightLine
|
distanceBasis: .straightLine
|
||||||
)
|
)
|
||||||
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
|
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
|
||||||
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
|
||||||
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLitresAmountPassesThrough() throws {
|
func testLitresAmountPassesThrough() throws {
|
||||||
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
||||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
|
||||||
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 2,
|
baselineJourneyMiles: 2,
|
||||||
candidateJourneyMiles: 8
|
candidateJourneyMiles: 8
|
||||||
)
|
)
|
||||||
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
|
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
|
XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.status, .worthTheDrive)
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidInputsRejected() {
|
func testInvalidInputsRejected() {
|
||||||
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
|
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0))
|
||||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
}
|
}
|
||||||
@@ -143,6 +143,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class FuelSavingsSettingsTests: XCTestCase {
|
final class FuelSavingsSettingsTests: XCTestCase {
|
||||||
|
func testSwitchingFillUnitStartsAnExplicitNewComparison() {
|
||||||
|
let budget = FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres)
|
||||||
|
XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres))
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds),
|
||||||
|
FuelSavingsFillAmount(value: 40, unit: .pounds))
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget)
|
||||||
|
}
|
||||||
|
|
||||||
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
||||||
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
@@ -150,3 +159,137 @@ final class FuelSavingsSettingsTests: XCTestCase {
|
|||||||
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The row's single recommendation badge. Presentation only, but the rules are
|
||||||
|
/// worth pinning: they decide which of two true statements a row makes, and
|
||||||
|
/// "no badge" is itself an answer.
|
||||||
|
final class StationRecommendationBadgeTests: XCTestCase {
|
||||||
|
private let threshold = 1.0
|
||||||
|
|
||||||
|
func testSavingAtTheThresholdEarnsBestValue() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 1.0, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.bestValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingBelowTheThresholdDoesNot() {
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestWithoutAWorthwhileSavingStillSaysCheapest() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.cheapest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cheapest station keeps its badge even when the drive costs more than
|
||||||
|
/// the saving buys — it IS the cheapest price; the capsule underneath is
|
||||||
|
/// what says the trip is not worth it.
|
||||||
|
func testCheapestKeepsItsBadgeWhenTheDriveCostsMoreThanItSaves() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: -14.30, isCheapest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.cheapest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One badge per row: the stronger statement wins.
|
||||||
|
func testBestValueOutranksCheapestWhenBothApply() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.bestValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A near-by station with no saving to speak of is context, not a
|
||||||
|
/// recommendation — the mockup gives NEAREST no capsule, and nothing else
|
||||||
|
/// here may invent one.
|
||||||
|
func testNoSavingAndNotCheapestMeansNoBadge() {
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold)
|
||||||
|
)
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnavailableSavingsLeaveOnlyTheCheapestBadge() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true,
|
||||||
|
bestValueThresholdGBP: threshold),
|
||||||
|
.cheapest
|
||||||
|
)
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rubbish figure must not become a recommendation.
|
||||||
|
func testNonFiniteFiguresProduceNoBadge() {
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: .nan, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: threshold)
|
||||||
|
)
|
||||||
|
XCTAssertNil(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 5.0, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: .nan)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The threshold is the user's alert setting: set it to zero and any saving
|
||||||
|
/// that is not a loss counts.
|
||||||
|
func testZeroThresholdAwardsBestValueToAnyNonNegativeSaving() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationRecommendationBadge.badge(netSavingGBP: 0, isCheapest: false,
|
||||||
|
bestValueThresholdGBP: 0),
|
||||||
|
.bestValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeySheetContentTests: XCTestCase {
|
||||||
|
func testPriceRatingsExplainTheImplementedRAGBoundaries() {
|
||||||
|
XCTAssertEqual(KeySheetContent.priceRatings, [
|
||||||
|
.init(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
.init(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
.init(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingsExplainVerdictsWithoutImplyingMinimumSavingQualification() {
|
||||||
|
XCTAssertEqual(KeySheetContent.savings, [
|
||||||
|
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
|
.init(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
|
.init(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
|
])
|
||||||
|
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
|
||||||
|
XCTAssertEqual(KeySheetContent.stationLabels, [
|
||||||
|
.init(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
||||||
|
.init(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
||||||
|
.init(label: "CLOSEST", description: "The nearest station by journey distance."),
|
||||||
|
.init(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavouriteAndHelpCopyRemainPinned() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeySheetContent.favourite,
|
||||||
|
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The Best Value gate's option list.
|
||||||
|
///
|
||||||
|
/// This one array is the figure behind two user-facing things — the BEST VALUE
|
||||||
|
/// badge on a station row and the proximity alert — so its granularity is a
|
||||||
|
/// setting, not an implementation detail. It was four coarse values (50p, £1,
|
||||||
|
/// £2, £5); these tests pin the 50p step and the £5 ceiling so a later edit
|
||||||
|
/// cannot quietly coarsen it back.
|
||||||
|
final class FuelStoreBestValueThresholdTests: XCTestCase {
|
||||||
|
func testOptionsStepByFiftyPenceUpToFivePounds() {
|
||||||
|
let options = FuelStore.bestValueAlertThresholdOptionsGBP
|
||||||
|
XCTAssertEqual(options, [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0])
|
||||||
|
XCTAssertEqual(options.count, 10)
|
||||||
|
XCTAssertEqual(options.first, 0.5, "the floor is the cheapest worth-the-drive saving offered")
|
||||||
|
XCTAssertEqual(options.last, 5.0, "£5 is the ceiling")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store writes the threshold as a string and, on load, rejects any
|
||||||
|
/// value that is not one of these options — falling back to the £1 default.
|
||||||
|
/// So an option that does not survive `String` → `Double` exactly would
|
||||||
|
/// silently discard the user's choice. Multiples of 0.5 are exactly
|
||||||
|
/// representable in binary, and this keeps them that way.
|
||||||
|
func testEveryOptionSurvivesTheStoresStringRoundTrip() {
|
||||||
|
for option in FuelStore.bestValueAlertThresholdOptionsGBP {
|
||||||
|
XCTAssertEqual(Double(String(option)), option,
|
||||||
|
"£\(option) does not round-trip through the store's string encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FuelStoreAlertModeTests: XCTestCase {
|
||||||
|
func testLegacyBestValueSelectionLoadsAsCheapest() {
|
||||||
|
FuelStore.saveAlertsMode(.bestValue)
|
||||||
|
defer { FuelStore.saveAlertsMode(.cheapest) }
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.loadAlertsMode(), .cheapest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Regression: a station's price age never removes it from Best Value.
|
||||||
|
///
|
||||||
|
/// The relay publishes one `price_updated` per station — the newest
|
||||||
|
/// `price_last_updated` across the grades it maps — which cannot vouch for the
|
||||||
|
/// selected grade. It is therefore display-only: Best Value, Cheapest and
|
||||||
|
/// Closest rank stations on their prices and distances exactly as before,
|
||||||
|
/// whether the timestamp is minutes, months or absent.
|
||||||
|
final class StationPriceAgePolicyTests: XCTestCase {
|
||||||
|
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
private let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
)
|
||||||
|
|
||||||
|
private func station(_ id: String,
|
||||||
|
price: Double,
|
||||||
|
fuel: FuelType = .e10,
|
||||||
|
ageDays: Double?,
|
||||||
|
lat: Double = 53,
|
||||||
|
lng: Double = -1) -> FuelStation {
|
||||||
|
FuelStation(
|
||||||
|
id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng,
|
||||||
|
prices: [fuel: price],
|
||||||
|
priceUpdated: ageDays.map { now.addingTimeInterval(-$0 * 86_400).timeIntervalSince1970 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||||||
|
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func distanceProvider(_ miles: [String: Double]) -> (FuelStation) -> FuelSavingsJourney? {
|
||||||
|
{ station in miles[station.id].map { self.straightLine($0) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The baseline is the closest seller, whatever its age
|
||||||
|
|
||||||
|
func testClosestSellerIsTheBaselineEvenWhenItsTimestampIsAncient() {
|
||||||
|
let ancientClosest = station("ancient-closest", price: 199.9, ageDays: 250)
|
||||||
|
let freshFurther = station("fresh-further", price: 170.9, ageDays: 1)
|
||||||
|
let journeys = distanceProvider(["ancient-closest": 0.8, "fresh-further": 3.0])
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsEstimator.baselineStation([ancientClosest, freshFurther], fuel: .e10,
|
||||||
|
journeyMiles: journeys)?.id,
|
||||||
|
"ancient-closest",
|
||||||
|
"the baseline is the closest station selling the selected fuel — age is not a criterion"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineStillRequiresTheSelectedFuelAndAUsableJourney() {
|
||||||
|
let wrongFuel = station("wrong-fuel", price: 140, fuel: .diesel, ageDays: 1)
|
||||||
|
let unusableJourney = station("bad-journey", price: 141, ageDays: 1)
|
||||||
|
let sellsFuel = station("sells-fuel", price: 145, ageDays: 1)
|
||||||
|
let journeys = distanceProvider(["wrong-fuel": 0.1, "sells-fuel": 2.0]) // bad-journey has none
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsEstimator.baselineStation([wrongFuel, unusableJourney, sellsFuel], fuel: .e10,
|
||||||
|
journeyMiles: journeys)?.id,
|
||||||
|
"sells-fuel"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Old, missing and invalid timestamps still rank
|
||||||
|
|
||||||
|
func testOldPriceTimestampsRankByNetSavingForEveryFuel() {
|
||||||
|
for fuel in FuelType.allCases {
|
||||||
|
let base = station("base", price: 199.9, fuel: fuel, ageDays: 250)
|
||||||
|
let cheap = station("cheap", price: 129.9, fuel: fuel, ageDays: 250)
|
||||||
|
let dear = station("dear", price: 198.9, fuel: fuel, ageDays: 250)
|
||||||
|
let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "dear": 1.0])
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked([dear, cheap, base], fuel: fuel, baseline: base,
|
||||||
|
settings: settings, amount: settings.preferredAmount,
|
||||||
|
journeyMiles: journeys)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.map(\.id), ["cheap", "dear", "base"], fuel.rawValue)
|
||||||
|
let saving = FuelSavingsEstimator.availability(
|
||||||
|
station: cheap, fuel: fuel, baseline: base, settings: settings,
|
||||||
|
amount: settings.preferredAmount, journey: straightLine(1.2), baselineJourney: straightLine(0.8)
|
||||||
|
)?.result
|
||||||
|
XCTAssertGreaterThan(saving?.netSavingGBP ?? -1, 0,
|
||||||
|
"\(fuel.rawValue): a 250-day-old price still produces a saving")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetSavingStillOutranksPumpPriceWhenTimestampsAreOld() {
|
||||||
|
// Net saving decides the order: the cheapest station is 30 miles away,
|
||||||
|
// so a pricier one a mile from home wins. Nothing here consults the age.
|
||||||
|
let base = station("base", price: 171.9, ageDays: 120)
|
||||||
|
let cheapFar = station("cheap-far", price: 169.9, ageDays: 250)
|
||||||
|
let pricierNear = station("pricier-near", price: 170.9, ageDays: 200)
|
||||||
|
let journeys = distanceProvider(["base": 0.8, "cheap-far": 30, "pricier-near": 1.2])
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked([cheapFar, pricierNear, base], fuel: .e10, baseline: base,
|
||||||
|
settings: settings, amount: settings.preferredAmount,
|
||||||
|
journeyMiles: journeys)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.map(\.id), ["pricier-near", "base", "cheap-far"])
|
||||||
|
XCTAssertEqual(ranked.first?.prices[.e10], 170.9, "the winner is chosen on net saving, not pump price")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFutureAndAbsurdTimestampsDoNotRemoveStations() {
|
||||||
|
let invalid = FuelStation(id: "absurd", name: "Absurd", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.e10: 150.9],
|
||||||
|
priceUpdated: -Double.greatestFiniteMagnitude)
|
||||||
|
let pool = [station("base", price: 199.9, ageDays: 250),
|
||||||
|
station("cheap", price: 129.9, ageDays: 100),
|
||||||
|
FuelStation(id: "future", name: "Future", brand: "", address: "", postcode: "",
|
||||||
|
lat: 53, lng: -1, prices: [.e10: 160.9],
|
||||||
|
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970),
|
||||||
|
station("undated", price: 170.9, ageDays: nil),
|
||||||
|
invalid]
|
||||||
|
let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "future": 1.6,
|
||||||
|
"undated": 2.0, "absurd": 2.8])
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
|
||||||
|
settings: settings, amount: settings.preferredAmount,
|
||||||
|
journeyMiles: journeys)
|
||||||
|
|
||||||
|
XCTAssertEqual(Set(ranked.map(\.id)), Set(pool.map(\.id)),
|
||||||
|
"every station stays in the pool: no timestamp can filter a row out")
|
||||||
|
XCTAssertEqual(ranked.first?.id, "cheap", "the cheapest station still wins")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryPricedStationKeepsAResultWhateverItsTimestamp() {
|
||||||
|
let pool = [
|
||||||
|
station("base", price: 199.9, ageDays: 1),
|
||||||
|
station("fresh", price: 180.9, ageDays: 1),
|
||||||
|
station("old", price: 175.9, ageDays: 40),
|
||||||
|
station("ancient", price: 170.9, ageDays: 250),
|
||||||
|
station("undated", price: 165.9, ageDays: nil)
|
||||||
|
]
|
||||||
|
let journeys = distanceProvider(["base": 0.8, "fresh": 1.0, "old": 1.4, "ancient": 1.8, "undated": 2.2])
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
|
||||||
|
settings: settings, amount: settings.preferredAmount,
|
||||||
|
journeyMiles: journeys)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.map(\.id).sorted(), ["ancient", "base", "fresh", "old", "undated"])
|
||||||
|
for station in pool {
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: station, fuel: .e10, baseline: pool[0], settings: settings,
|
||||||
|
amount: settings.preferredAmount,
|
||||||
|
journey: journeys(station), baselineJourney: straightLine(0.8)
|
||||||
|
)?.result
|
||||||
|
XCTAssertNotNil(result, "\(station.id) still shows a Best Value row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheapestAndClosestOrdersAreUnaffectedByTimestampAge() {
|
||||||
|
// Cheapest sorts on pump price, Closest on distance — the only inputs are
|
||||||
|
// prices and distances, so no age combination changes either order.
|
||||||
|
let specs: [(String, Double, Double)] = [("a", 199.9, 0.8), ("b", 129.9, 30), ("c", 170.9, 1.2)]
|
||||||
|
for ages in [[1.0, 1.0, 1.0], [250.0, 250.0, 250.0]] {
|
||||||
|
let pool = zip(specs, ages).map { spec, age in
|
||||||
|
station(spec.0, price: spec.1, ageDays: age)
|
||||||
|
}
|
||||||
|
let journeys = distanceProvider(Dictionary(uniqueKeysWithValues:
|
||||||
|
zip(specs.map(\.0), specs.map(\.2))))
|
||||||
|
|
||||||
|
let cheapestFirst = pool.sorted { lhs, rhs in
|
||||||
|
let left = lhs.prices[.e10] ?? .infinity
|
||||||
|
let right = rhs.prices[.e10] ?? .infinity
|
||||||
|
if left != right { return left < right }
|
||||||
|
return (journeys(lhs)?.miles ?? .infinity) < (journeys(rhs)?.miles ?? .infinity)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(cheapestFirst.map(\.id), ["b", "c", "a"])
|
||||||
|
|
||||||
|
let closestFirst = pool.sorted { lhs, rhs in
|
||||||
|
let left = journeys(lhs)?.miles ?? .infinity
|
||||||
|
let right = journeys(rhs)?.miles ?? .infinity
|
||||||
|
if left != right { return left < right }
|
||||||
|
return (lhs.prices[.e10] ?? .infinity) < (rhs.prices[.e10] ?? .infinity)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(closestFirst.map(\.id), ["a", "c", "b"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The bundled / offline dataset
|
||||||
|
|
||||||
|
func testBundledDatasetWithMonthOldTimestampsStillRecommendsBestValue() {
|
||||||
|
// The bundled dump ships one pull of the whole UK feed, so every station
|
||||||
|
// arrives with the same months-old timestamp. Best Value must still
|
||||||
|
// recommend — it used to read "unavailable" for the entire offline list.
|
||||||
|
var specs: [(String, Double, Double, Double?)] = [("base", 171.9, 0.8, 90),
|
||||||
|
("pricier-near", 170.9, 1.2, 200),
|
||||||
|
("cheap-far", 149.9, 40, 250),
|
||||||
|
("dear-near", 179.9, 1.6, nil),
|
||||||
|
("old-undated", 160.9, 5.0, 240)]
|
||||||
|
let pool = specs.map { station($0.0, price: $0.1, ageDays: $0.3) }
|
||||||
|
let journeys = distanceProvider(Dictionary(uniqueKeysWithValues:
|
||||||
|
specs.map { ($0.0, $0.2) }))
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0],
|
||||||
|
settings: settings, amount: settings.preferredAmount,
|
||||||
|
journeyMiles: journeys)
|
||||||
|
|
||||||
|
let savings = pool.map { station in
|
||||||
|
(station.id, FuelSavingsEstimator.availability(
|
||||||
|
station: station, fuel: .e10, baseline: pool[0], settings: settings,
|
||||||
|
amount: settings.preferredAmount, journey: journeys(station), baselineJourney: straightLine(0.8)
|
||||||
|
)?.result?.netSavingGBP ?? -Double.infinity)
|
||||||
|
}
|
||||||
|
let expectedWinner = savings.max { $0.1 < $1.1 }?.0
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.first?.id, expectedWinner,
|
||||||
|
"the offline list is still ordered by net saving")
|
||||||
|
XCTAssertNotNil(ranked.first?.prices[.e10], "the top station has a price to show")
|
||||||
|
XCTAssertGreaterThan(savings.first { $0.0 == expectedWinner }?.1 ?? -1, 0,
|
||||||
|
"and that recommendation carries a real saving")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ private enum WatchDistanceUnit: String, Codable {
|
|||||||
func format(km: Double) -> String {
|
func format(km: Double) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .miles:
|
case .miles:
|
||||||
return String(format: "%.1f mi", km * 0.621371)
|
return String(format: "%.1f mi", km / 1.609344)
|
||||||
case .kilometers:
|
case .kilometers:
|
||||||
return String(format: "%.1f km", km)
|
return String(format: "%.1f km", km)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
data: FuelPriceWidgetEntryData(
|
data: FuelPriceWidgetEntryData(
|
||||||
date: Date(), stations: Array(sample.prefix(8)),
|
date: Date(), stations: Array(sample.prefix(8)),
|
||||||
fuel: .e10, sort: .cheapest, isFavourites: false,
|
fuel: .e10, sort: .cheapest, isFavourites: false,
|
||||||
location: nil, locationSource: "none", unit: unit
|
location: nil, locationSource: "none", unit: unit,
|
||||||
|
bestValueResult: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -146,8 +147,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||||
let isFavourites = configuration.sort == .favourites
|
let isFavourites = configuration.sort == .favourites
|
||||||
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
let sort: SortMode = configuration.sort == .closest ? .closest :
|
||||||
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
(configuration.sort == .worthTheDrive ? .worthTheDrive : .cheapest)
|
||||||
|
let radiusKM = Double(configuration.distance.miles) * FuelSavingsCalculator.kilometresPerMile
|
||||||
|
|
||||||
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
|
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
|
||||||
// accuracy is kept: the extension runs its own CLLocationManager and is
|
// accuracy is kept: the extension runs its own CLLocationManager and is
|
||||||
@@ -200,6 +202,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
|
|
||||||
// 3) Load + order stations per widget mode.
|
// 3) Load + order stations per widget mode.
|
||||||
let ordered: [FuelStation]
|
let ordered: [FuelStation]
|
||||||
|
var bestValueResult: FuelSavingsResult?
|
||||||
if isFavourites {
|
if isFavourites {
|
||||||
let favourites = FuelStore.loadFavourites()
|
let favourites = FuelStore.loadFavourites()
|
||||||
.filter { $0.station.prices[$0.fuel] != nil }
|
.filter { $0.station.prices[$0.fuel] != nil }
|
||||||
@@ -265,7 +268,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||||
switch sort {
|
switch sort {
|
||||||
case .closest:
|
case .closest:
|
||||||
// Nearest first; price only breaks ties.
|
// Closest first; price only breaks ties.
|
||||||
ordered = filtered.sorted { lhs, rhs in
|
ordered = filtered.sorted { lhs, rhs in
|
||||||
if let origin {
|
if let origin {
|
||||||
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
|
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
|
||||||
@@ -274,9 +277,59 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||||
}
|
}
|
||||||
case .cheapest, .worthTheDrive:
|
case .worthTheDrive:
|
||||||
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only;
|
guard let origin else {
|
||||||
// widgets fall back to cheapest ordering if a stored config ever carries it.
|
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let journey: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||||
|
if let meters = FuelStore.roadJourneyMeters(for: station, userLat: origin.lat, userLng: origin.lng) {
|
||||||
|
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
|
||||||
|
for: station, userLat: origin.lat, userLng: origin.lng
|
||||||
|
) ? .confirmed : .estimated
|
||||||
|
return FuelSavingsJourney(
|
||||||
|
miles: meters / FuelSavingsCalculator.metresPerMile,
|
||||||
|
basis: .roadRoute,
|
||||||
|
confidence: confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return FuelSavingsJourney(
|
||||||
|
miles: FuelStore.displayDistanceKM(station: station, userLat: origin.lat, userLng: origin.lng) /
|
||||||
|
FuelSavingsCalculator.kilometresPerMile * 2,
|
||||||
|
basis: .straightLine,
|
||||||
|
confidence: .estimated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let settings = FuelStore.loadFuelSavingsSettings()
|
||||||
|
guard settings.isConfigured else {
|
||||||
|
ordered = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let amount = settings.preferredAmount
|
||||||
|
let baseline = FuelSavingsEstimator.baselineStation(filtered, fuel: fuel, journeyMiles: journey)
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
filtered,
|
||||||
|
fuel: fuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: journey
|
||||||
|
)
|
||||||
|
ordered = ranked
|
||||||
|
if let first = ranked.first, let baseline {
|
||||||
|
let baselineJourney = journey(baseline)
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: first,
|
||||||
|
fuel: fuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: journey(first),
|
||||||
|
baselineJourney: baselineJourney
|
||||||
|
)
|
||||||
|
bestValueResult = result?.result
|
||||||
|
}
|
||||||
|
case .cheapest:
|
||||||
ordered = filtered.sorted { lhs, rhs in
|
ordered = filtered.sorted { lhs, rhs in
|
||||||
let lPrice = lhs.prices[fuel]!
|
let lPrice = lhs.prices[fuel]!
|
||||||
let rPrice = rhs.prices[fuel]!
|
let rPrice = rhs.prices[fuel]!
|
||||||
@@ -295,7 +348,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
date: Date(), stations: Array(ordered.prefix(8)),
|
date: Date(), stations: Array(ordered.prefix(8)),
|
||||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||||
location: origin, locationSource: source,
|
location: origin, locationSource: source,
|
||||||
unit: unit
|
unit: unit, bestValueResult: bestValueResult
|
||||||
)
|
)
|
||||||
), originSource)
|
), originSource)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ enum WidgetFuel: String, AppEnum, CaseIterable, Codable {
|
|||||||
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
||||||
case cheapest
|
case cheapest
|
||||||
case closest
|
case closest
|
||||||
|
case worthTheDrive
|
||||||
case favourites
|
case favourites
|
||||||
|
|
||||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Sort by"
|
||||||
@@ -37,6 +38,7 @@ enum WidgetSort: String, AppEnum, CaseIterable, Codable {
|
|||||||
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
|
static var caseDisplayRepresentations: [WidgetSort: DisplayRepresentation] = [
|
||||||
.cheapest: "Cheapest",
|
.cheapest: "Cheapest",
|
||||||
.closest: "Closest",
|
.closest: "Closest",
|
||||||
|
.worthTheDrive: "Best Value",
|
||||||
.favourites: "Favourites",
|
.favourites: "Favourites",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,13 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
|
|||||||
\.$fuel
|
\.$fuel
|
||||||
\.$sort
|
\.$sort
|
||||||
}
|
}
|
||||||
|
} otherwise: {
|
||||||
|
When(\.$sort, .equalTo, WidgetSort.worthTheDrive) {
|
||||||
|
Summary("Show \(\.$fuel) by \(\.$sort) within \(\.$distance)") {
|
||||||
|
\.$fuel
|
||||||
|
\.$sort
|
||||||
|
\.$distance
|
||||||
|
}
|
||||||
} otherwise: {
|
} otherwise: {
|
||||||
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
Summary("Show \(\.$fuel) by \(\.$sort)") {
|
||||||
\.$fuel
|
\.$fuel
|
||||||
@@ -119,4 +128,5 @@ struct FuelBoardWidgetConfigurationIntent: WidgetConfigurationIntent, WidgetConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ enum DataRefreshMode: String, CaseIterable, Identifiable {
|
|||||||
var summary: String {
|
var summary: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .standard:
|
case .standard:
|
||||||
return "Saves battery. Refreshes price data up to twice daily."
|
return "Saves battery by checking for new prices up to twice a day."
|
||||||
case .smart:
|
case .smart:
|
||||||
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
return "Checks for new prices more often, keeping your data fresher."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FuelPriceTimestamp {
|
||||||
|
static func parse(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter.date(from: value) ?? {
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: value)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Station-level price freshness — informational only.
|
||||||
|
///
|
||||||
|
/// The relay publishes ONE `price_updated` per station, derived from the
|
||||||
|
/// GOV.UK Fuel Finder field `price_last_updated` as the MAXIMUM across the
|
||||||
|
/// fuel grades it maps. It is therefore a station-level figure and never a
|
||||||
|
/// verified timestamp for the grade the user has selected, so every label
|
||||||
|
/// describes the station's price information rather than one fuel.
|
||||||
|
///
|
||||||
|
/// Age excludes nothing: a station with a valid price is ranked on that price
|
||||||
|
/// however old its timestamp is. `outdatedAge` only decides whether More info
|
||||||
|
/// adds a discreet "May be outdated" note.
|
||||||
|
enum FuelPriceFreshness {
|
||||||
|
/// Price information older than this is flagged as possibly out of date.
|
||||||
|
/// A display threshold only — it gates nothing.
|
||||||
|
static let outdatedAge: TimeInterval = 30 * 24 * 60 * 60
|
||||||
|
/// Tolerated clock skew. A timestamp further into the future than this is
|
||||||
|
/// unusable (a feed error), never silently treated as "just now".
|
||||||
|
static let futureTolerance: TimeInterval = 5 * 60
|
||||||
|
/// Ages beyond this are unusable rather than displayed. It also keeps the
|
||||||
|
/// day count inside `Int`, so an absurd epoch cannot trap.
|
||||||
|
static let maximumAge: TimeInterval = 365 * 24 * 60 * 60 * 10
|
||||||
|
|
||||||
|
enum Status: Equatable {
|
||||||
|
/// Readable timestamp within `outdatedAge`.
|
||||||
|
case fresh
|
||||||
|
/// Readable timestamp older than `outdatedAge`.
|
||||||
|
case outdated
|
||||||
|
/// The record carries no timestamp.
|
||||||
|
case unknown
|
||||||
|
/// A timestamp is present but unreadable, or implausibly in the future.
|
||||||
|
case invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Assessment: Equatable {
|
||||||
|
let status: Status
|
||||||
|
/// Age in seconds. nil exactly when the timestamp is unusable, so no
|
||||||
|
/// caller can print an age that was never measured.
|
||||||
|
let age: TimeInterval?
|
||||||
|
|
||||||
|
var isOutdated: Bool { status == .outdated }
|
||||||
|
|
||||||
|
/// The one age More info reports. Missing or unreadable timestamps read
|
||||||
|
/// "Not provided" rather than implying freshness.
|
||||||
|
var updateAgeLabel: String {
|
||||||
|
guard let age else {
|
||||||
|
return String(localized: "Not provided", comment: "Price update time is missing")
|
||||||
|
}
|
||||||
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
||||||
|
Self.ageText(age))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discreet flag for price information past `outdatedAge`. nil while the
|
||||||
|
/// information is fresh, and nil when there is nothing to measure.
|
||||||
|
var outdatedNotice: String? {
|
||||||
|
guard status == .outdated else { return nil }
|
||||||
|
return String(localized: "May be outdated",
|
||||||
|
comment: "Station price information older than 30 days")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact age: minutes under an hour, hours under two days, then days.
|
||||||
|
static func ageText(_ age: TimeInterval) -> String {
|
||||||
|
let minutes = max(0, Int(age / 60))
|
||||||
|
if minutes < 60 { return "\(minutes)m" }
|
||||||
|
let hours = minutes / 60
|
||||||
|
if hours < 48 { return "\(hours)h" }
|
||||||
|
return "\(hours / 24)d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The station's own timestamp. Deliberately takes no fuel: there is one
|
||||||
|
/// figure per station and every grade reads the same one, so the policy
|
||||||
|
/// cannot differ between fuels.
|
||||||
|
static func assessment(for station: FuelStation, now: Date = Date()) -> Assessment {
|
||||||
|
assessment(priceUpdated: station.priceUpdated, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func assessment(priceUpdated: TimeInterval?, now: Date = Date()) -> Assessment {
|
||||||
|
guard let priceUpdated else { return Assessment(status: .unknown, age: nil) }
|
||||||
|
guard priceUpdated.isFinite else { return Assessment(status: .invalid, age: nil) }
|
||||||
|
let age = now.timeIntervalSince1970 - priceUpdated
|
||||||
|
guard age.isFinite, age >= -futureTolerance, age <= maximumAge else {
|
||||||
|
return Assessment(status: .invalid, age: nil)
|
||||||
|
}
|
||||||
|
let boundedAge = max(0, age)
|
||||||
|
return Assessment(status: boundedAge > outdatedAge ? .outdated : .fresh, age: boundedAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ enum FuelPriceProvider {
|
|||||||
lat: relay.lat ?? 0,
|
lat: relay.lat ?? 0,
|
||||||
lng: relay.lng ?? 0,
|
lng: relay.lng ?? 0,
|
||||||
prices: relay.allPrices,
|
prices: relay.allPrices,
|
||||||
priceUpdated: nil
|
priceUpdated: relay.priceUpdatedDate?.timeIntervalSince1970
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,15 @@ enum FuelPriceProvider {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func fuelType(for grade: String) -> FuelType? {
|
||||||
|
switch grade.uppercased() {
|
||||||
|
case "E10": return .e10
|
||||||
|
case "E5": return .e5
|
||||||
|
case "DIESEL", "B7", "B7S", "B7P", "B10": return .diesel
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
||||||
/// About section shows these so the user can see which data source is
|
/// About section shows these so the user can see which data source is
|
||||||
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
||||||
@@ -106,6 +115,16 @@ private struct MirrorResponse: Codable {
|
|||||||
let lng: Double?
|
let lng: Double?
|
||||||
let price: Double?
|
let price: Double?
|
||||||
let prices: [String: Double]?
|
let prices: [String: Double]?
|
||||||
|
let priceUpdated: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, brand, address, postcode, lat, lng, price, prices
|
||||||
|
case priceUpdated = "price_updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
var priceUpdatedDate: Date? {
|
||||||
|
FuelPriceTimestamp.parse(priceUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||||
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ struct FuelPriceWidgetEntryData {
|
|||||||
let date: Date
|
let date: Date
|
||||||
let stations: [FuelStation] // already filtered + sorted per config
|
let stations: [FuelStation] // already filtered + sorted per config
|
||||||
let fuel: FuelType
|
let fuel: FuelType
|
||||||
let sort: SortMode // per-widget: cheapest | closest
|
let sort: SortMode // per-widget: cheapest | closest | Best Value
|
||||||
let isFavourites: Bool // favourites mode: pinned stations, cheapest-first
|
let isFavourites: Bool // favourites mode: pinned stations
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let locationSource: String // "live" | "cached" | "none"
|
let locationSource: String // "live" | "cached" | "none"
|
||||||
let unit: DistanceUnit // user's display unit for distances
|
let unit: DistanceUnit // user's display unit for distances
|
||||||
|
let bestValueResult: FuelSavingsResult? // only populated for the small Best Value face
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widget face, parameterised by family so the app can preview it.
|
/// The widget face, parameterised by family so the app can preview it.
|
||||||
@@ -67,9 +68,11 @@ struct FuelPriceWidgetContent: View {
|
|||||||
|
|
||||||
private var heading: String {
|
private var heading: String {
|
||||||
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" }
|
||||||
return entry.sort == .closest
|
switch entry.sort {
|
||||||
? "Closest \(entry.fuel.displayName)"
|
case .closest: return "Closest \(entry.fuel.displayName)"
|
||||||
: "Cheapest \(entry.fuel.displayName)"
|
case .worthTheDrive: return "Best Value \(entry.fuel.displayName)"
|
||||||
|
case .cheapest: return "Cheapest \(entry.fuel.displayName)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var singleStation: some View {
|
private var singleStation: some View {
|
||||||
@@ -85,9 +88,27 @@ struct FuelPriceWidgetContent: View {
|
|||||||
Text(station.name)
|
Text(station.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
if entry.sort == .worthTheDrive, let result = entry.bestValueResult {
|
||||||
|
Text(result.headline(compact: true))
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(result.status == .baseline ? "your baseline" : "vs closest station")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else if entry.sort == .worthTheDrive {
|
||||||
if let price = station.prices[entry.fuel] {
|
if let price = station.prices[entry.fuel] {
|
||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
|
if entry.location == nil {
|
||||||
|
Text("Best Value unavailable · location needed")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
} else if let price = station.prices[entry.fuel] {
|
||||||
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
let distance = FuelStore.stationDistanceDisplay(
|
let distance = FuelStore.stationDistanceDisplay(
|
||||||
station: station, userLat: location.lat, userLng: location.lng
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
@@ -126,7 +147,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(entry.isFavourites ? "cheapest first"
|
Text(entry.isFavourites ? "your order"
|
||||||
: (entry.locationSource == "none" ? "by price" : "near you"))
|
: (entry.locationSource == "none" ? "by price" : "near you"))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
+204
-16
@@ -82,6 +82,7 @@ enum JourneyConfidence: String, Codable, Equatable {
|
|||||||
struct FuelSavingsResult: Codable, Equatable {
|
struct FuelSavingsResult: Codable, Equatable {
|
||||||
let fillLitres: Double
|
let fillLitres: Double
|
||||||
let grossSavingGBP: Double
|
let grossSavingGBP: Double
|
||||||
|
let extraFuelLitres: Double
|
||||||
let extraDrivingCostGBP: Double
|
let extraDrivingCostGBP: Double
|
||||||
let extraDistanceMiles: Double
|
let extraDistanceMiles: Double
|
||||||
let netSavingGBP: Double
|
let netSavingGBP: Double
|
||||||
@@ -91,6 +92,7 @@ struct FuelSavingsResult: Codable, Equatable {
|
|||||||
|
|
||||||
init(fillLitres: Double,
|
init(fillLitres: Double,
|
||||||
grossSavingGBP: Double,
|
grossSavingGBP: Double,
|
||||||
|
extraFuelLitres: Double = 0,
|
||||||
extraDrivingCostGBP: Double,
|
extraDrivingCostGBP: Double,
|
||||||
extraDistanceMiles: Double,
|
extraDistanceMiles: Double,
|
||||||
netSavingGBP: Double,
|
netSavingGBP: Double,
|
||||||
@@ -99,6 +101,7 @@ struct FuelSavingsResult: Codable, Equatable {
|
|||||||
journeyConfidence: JourneyConfidence? = nil) {
|
journeyConfidence: JourneyConfidence? = nil) {
|
||||||
self.fillLitres = fillLitres
|
self.fillLitres = fillLitres
|
||||||
self.grossSavingGBP = grossSavingGBP
|
self.grossSavingGBP = grossSavingGBP
|
||||||
|
self.extraFuelLitres = extraFuelLitres
|
||||||
self.extraDrivingCostGBP = extraDrivingCostGBP
|
self.extraDrivingCostGBP = extraDrivingCostGBP
|
||||||
self.extraDistanceMiles = extraDistanceMiles
|
self.extraDistanceMiles = extraDistanceMiles
|
||||||
self.netSavingGBP = netSavingGBP
|
self.netSavingGBP = netSavingGBP
|
||||||
@@ -133,11 +136,32 @@ enum FuelSavingsFormat {
|
|||||||
static func extraTravelLabel(extraDistanceMiles: Double,
|
static func extraTravelLabel(extraDistanceMiles: Double,
|
||||||
unit: DistanceUnit,
|
unit: DistanceUnit,
|
||||||
confidence: JourneyConfidence = .confirmed) -> String? {
|
confidence: JourneyConfidence = .confirmed) -> String? {
|
||||||
|
guard let distanceText = extraTravelDistanceLabel(extraDistanceMiles: extraDistanceMiles,
|
||||||
|
unit: unit,
|
||||||
|
confidence: confidence) else { return nil }
|
||||||
|
return "\(distanceText) extra return travel"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extra-travel FIGURE on its own, with its confidence marker: "4.3 mi"
|
||||||
|
/// or "Approx. 4.3 mi".
|
||||||
|
///
|
||||||
|
/// A row form ("Extra return travel | 4.3 mi") names the journey in its own
|
||||||
|
/// label, so this is the same figure and the same "Approx." rule as
|
||||||
|
/// `extraTravelLabel`, from the same inputs — never rebuilt at a call site,
|
||||||
|
/// or one surface marks an estimate while another drops the marker.
|
||||||
|
static func extraTravelDistanceLabel(extraDistanceMiles: Double,
|
||||||
|
unit: DistanceUnit,
|
||||||
|
confidence: JourneyConfidence = .confirmed) -> String? {
|
||||||
guard extraDistanceMiles > 0.05 else { return nil }
|
guard extraDistanceMiles > 0.05 else { return nil }
|
||||||
let distanceText = distance(extraDistanceMiles, unit: unit)
|
let distanceText = distance(extraDistanceMiles, unit: unit)
|
||||||
return confidence == .estimated
|
return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
|
||||||
? "Approx. \(distanceText) extra return travel"
|
}
|
||||||
: "\(distanceText) extra return travel"
|
|
||||||
|
/// Compact comparison copy kept outside the coloured verdict capsule.
|
||||||
|
/// "Closest" refers to the existing shortest-journey baseline selection;
|
||||||
|
/// it does not claim a straight-line comparison.
|
||||||
|
static func comparisonLabel(status: FuelSavingsStatus) -> String {
|
||||||
|
status == .baseline ? "your baseline" : "vs closest station"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||||||
@@ -174,6 +198,7 @@ extension FuelSavingsResult {
|
|||||||
FuelSavingsResult(
|
FuelSavingsResult(
|
||||||
fillLitres: fillLitres,
|
fillLitres: fillLitres,
|
||||||
grossSavingGBP: 0,
|
grossSavingGBP: 0,
|
||||||
|
extraFuelLitres: 0,
|
||||||
extraDrivingCostGBP: 0,
|
extraDrivingCostGBP: 0,
|
||||||
extraDistanceMiles: 0,
|
extraDistanceMiles: 0,
|
||||||
netSavingGBP: 0,
|
netSavingGBP: 0,
|
||||||
@@ -199,9 +224,9 @@ extension FuelSavingsResult {
|
|||||||
case .breakEven:
|
case .breakEven:
|
||||||
return Self.breakEvenHeadline
|
return Self.breakEvenHeadline
|
||||||
case .worthTheDrive:
|
case .worthTheDrive:
|
||||||
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
|
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
|
||||||
case .notWorthTheDrive:
|
case .notWorthTheDrive:
|
||||||
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
|
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,13 +236,99 @@ extension FuelSavingsResult {
|
|||||||
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row capsule copy: the headline, plus what the baseline row IS. Without the
|
/// Full monetary verdict used by Station Details. Estimated confidence is a
|
||||||
/// suffix the baseline capsule just repeated the status word above it
|
/// trailing qualifier so the amount remains the first figure users scan.
|
||||||
/// ("Closest station" twice) and said nothing about the saving being measured
|
func detailCapsuleHeadline() -> String {
|
||||||
/// against it.
|
let qualifier = journeyConfidence.isEstimated ? " (approx.)" : ""
|
||||||
|
switch status {
|
||||||
|
case .baseline:
|
||||||
|
return Self.baselineHeadline
|
||||||
|
case .breakEven:
|
||||||
|
return "\(Self.breakEvenHeadline)\(qualifier)"
|
||||||
|
case .worthTheDrive:
|
||||||
|
return "Save \(FuelSavingsFormat.money(netSavingGBP))\(qualifier)"
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return "Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more\(qualifier)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailCapsuleSpokenHeadline() -> String {
|
||||||
|
detailCapsuleHeadline().replacingOccurrences(of: "(approx.)", with: "(approximately)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row capsule copy. The baseline relationship is shown as secondary text
|
||||||
|
/// beside the capsule in the station listing and detail summary, so the
|
||||||
|
/// capsule itself contains only the verdict.
|
||||||
func capsuleHeadline() -> String {
|
func capsuleHeadline() -> String {
|
||||||
let headline = headline(compact: true)
|
headline(compact: true)
|
||||||
return status == .baseline ? "\(headline) · your baseline" : headline
|
}
|
||||||
|
|
||||||
|
/// The station-listing capsule's verdict, split into the lines the capsule
|
||||||
|
/// draws.
|
||||||
|
///
|
||||||
|
/// The amount always takes its OWN line, so where the capsule breaks is
|
||||||
|
/// decided by the data, never by the width it was handed: the figure can
|
||||||
|
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||||
|
/// instead of widening the row's fixed price column.
|
||||||
|
struct ListingVerdict: Equatable {
|
||||||
|
/// "Saves", "Costs", "Break even" or "Closest station".
|
||||||
|
let headline: String
|
||||||
|
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||||
|
let amount: String?
|
||||||
|
/// "(approx.)" when the journey behind the figure was estimated.
|
||||||
|
let qualifier: String?
|
||||||
|
|
||||||
|
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||||||
|
|
||||||
|
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||||||
|
/// in the middle of a phrase when spoken.
|
||||||
|
var spokenText: String { lines.joined(separator: " ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verdict for the station listing rows — Stations and Favourites share one
|
||||||
|
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||||||
|
/// which demonstrates the wording without inventing a figure.
|
||||||
|
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Saves"),
|
||||||
|
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Costs"),
|
||||||
|
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .breakEven:
|
||||||
|
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||||||
|
// estimate qualifier adds its own.
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Break even"),
|
||||||
|
amount: nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .baseline:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: Self.baselineHeadline,
|
||||||
|
amount: nil,
|
||||||
|
qualifier: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||||||
|
private static func amountWithMore(_ value: Double) -> String {
|
||||||
|
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line verdict for voice and tests; the capsule itself renders
|
||||||
|
/// `listingVerdict()`'s lines separately.
|
||||||
|
func listingCapsuleHeadline() -> String {
|
||||||
|
listingVerdict().spokenText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +364,65 @@ struct FuelSavingsRankedStation: Equatable {
|
|||||||
let pricePencePerLitre: Double
|
let pricePencePerLitre: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
|
||||||
|
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
|
||||||
|
/// each visual indicator without coupling the wording to SwiftUI layout.
|
||||||
|
enum KeySheetContent {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let label: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static let priceRatings = [
|
||||||
|
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let savings = [
|
||||||
|
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
|
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
|
Item(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let stationLabels = [
|
||||||
|
Item(label: "BEST VALUE", description: "Meets your minimum saving after travel costs."),
|
||||||
|
Item(label: "CHEAPEST", description: "The lowest fuel price per litre within your selected range."),
|
||||||
|
Item(label: "CLOSEST", description: "The nearest station by journey distance."),
|
||||||
|
Item(label: "TOP", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let favourite = Item(
|
||||||
|
label: "Favourites",
|
||||||
|
description: "Star a station to save it to your Favourites."
|
||||||
|
)
|
||||||
|
|
||||||
|
static let helpTitle = "How does Best Value work?"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which recommendation badge a station listing row shows, if any.
|
||||||
|
///
|
||||||
|
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
||||||
|
/// app's existing Best Value gate (the user's alert threshold, the same figure
|
||||||
|
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
|
||||||
|
/// reuses the caller's existing "cheapest in range" flag (the station behind
|
||||||
|
/// `topStationID`). One badge per row: a station that is both the cheapest and
|
||||||
|
/// worth the drive shows BEST VALUE, the stronger of the two.
|
||||||
|
enum StationRecommendationBadge: Equatable {
|
||||||
|
case bestValue
|
||||||
|
case cheapest
|
||||||
|
|
||||||
|
static func badge(netSavingGBP: Double?,
|
||||||
|
isCheapest: Bool,
|
||||||
|
bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
|
||||||
|
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
|
||||||
|
netSavingGBP >= bestValueThresholdGBP {
|
||||||
|
return .bestValue
|
||||||
|
}
|
||||||
|
return isCheapest ? .cheapest : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct BestValueAlertCandidate: Equatable {
|
struct BestValueAlertCandidate: Equatable {
|
||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let result: FuelSavingsResult
|
let result: FuelSavingsResult
|
||||||
@@ -321,16 +491,29 @@ enum FuelSavingsCalculator {
|
|||||||
distanceMiles / mpg * imperialGallonLitres
|
distanceMiles / mpg * imperialGallonLitres
|
||||||
}
|
}
|
||||||
|
|
||||||
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
|
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
|
||||||
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||||
switch amount.unit {
|
switch amount.unit {
|
||||||
case .litres:
|
case .litres:
|
||||||
return amount.value
|
return amount.value
|
||||||
case .pounds:
|
case .pounds:
|
||||||
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
|
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
|
||||||
throw FuelSavingsError.invalidPrice
|
throw FuelSavingsError.invalidPrice
|
||||||
}
|
}
|
||||||
return amount.value / (baselinePricePencePerLitre / 100.0)
|
return amount.value / (pricePencePerLitre / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changing between a per-candidate monetary budget and a fixed litre
|
||||||
|
/// quantity has no lossless conversion across all stations. Start a new
|
||||||
|
/// comparison at that mode's stated default; the UI must tell the user
|
||||||
|
/// explicitly that the comparison basis changed and invite adjustment.
|
||||||
|
static func newComparisonAmount(afterSwitching amount: FuelSavingsFillAmount,
|
||||||
|
to unit: FuelSavingsFillUnit) -> FuelSavingsFillAmount {
|
||||||
|
guard amount.unit != unit else { return amount }
|
||||||
|
switch unit {
|
||||||
|
case .pounds: return FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
case .litres: return FuelSavingsFillAmount(value: 30, unit: .litres)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +559,7 @@ enum FuelSavingsCalculator {
|
|||||||
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||||||
throw FuelSavingsError.invalidDistance
|
throw FuelSavingsError.invalidDistance
|
||||||
}
|
}
|
||||||
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
|
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
|
||||||
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||||||
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||||||
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||||||
@@ -395,6 +578,7 @@ enum FuelSavingsCalculator {
|
|||||||
return FuelSavingsResult(
|
return FuelSavingsResult(
|
||||||
fillLitres: fillLitres,
|
fillLitres: fillLitres,
|
||||||
grossSavingGBP: grossSaving,
|
grossSavingGBP: grossSaving,
|
||||||
|
extraFuelLitres: extraFuelLitres,
|
||||||
extraDrivingCostGBP: extraFuelCost,
|
extraDrivingCostGBP: extraFuelCost,
|
||||||
extraDistanceMiles: extraDistance,
|
extraDistanceMiles: extraDistance,
|
||||||
netSavingGBP: netSaving,
|
netSavingGBP: netSaving,
|
||||||
@@ -572,7 +756,11 @@ enum FuelSavingsEstimator {
|
|||||||
journeys[station.id] = journey
|
journeys[station.id] = journey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let baselineJourney = baseline.flatMap { journeys[$0.id] }
|
// The baseline is normally in the pool, so this is the same lookup it has
|
||||||
|
// always been; asking for its journey directly covers the case where the
|
||||||
|
// list being ranked is filtered tighter than the baseline was chosen from,
|
||||||
|
// which would otherwise mark every row unavailable.
|
||||||
|
let baselineJourney = baseline.flatMap { journeys[$0.id] ?? journeyMiles($0) }
|
||||||
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||||||
let journey = journeys[station.id]
|
let journey = journeys[station.id]
|
||||||
let availability = availability(station: station,
|
let availability = availability(station: station,
|
||||||
|
|||||||
+166
-38
@@ -183,13 +183,13 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
|||||||
/// shown in this unit, rounded to a whole number for picker labels:
|
/// shown in this unit, rounded to a whole number for picker labels:
|
||||||
/// 5 miles -> "5" (miles unit) or "8" (km unit).
|
/// 5 miles -> "5" (miles unit) or "8" (km unit).
|
||||||
func displayMiles(_ miles: Int) -> Int {
|
func displayMiles(_ miles: Int) -> Int {
|
||||||
Int(fromKM(Double(miles) * 1.60934).rounded())
|
Int(fromKM(Double(miles) * FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a value expressed in this unit to km.
|
/// Convert a value expressed in this unit to km.
|
||||||
func toKM(_ value: Double) -> Double {
|
func toKM(_ value: Double) -> Double {
|
||||||
switch self {
|
switch self {
|
||||||
case .miles: return value * 1.60934
|
case .miles: return value * FuelSavingsCalculator.kilometresPerMile
|
||||||
case .kilometers: return value
|
case .kilometers: return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
|||||||
/// Convert a km value to this unit.
|
/// Convert a km value to this unit.
|
||||||
func fromKM(_ km: Double) -> Double {
|
func fromKM(_ km: Double) -> Double {
|
||||||
switch self {
|
switch self {
|
||||||
case .miles: return km * 0.621371
|
case .miles: return km / FuelSavingsCalculator.kilometresPerMile
|
||||||
case .kilometers: return km
|
case .kilometers: return km
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .stationSign: return "Station sign"
|
case .stationSign: return "Pence per litre"
|
||||||
case .poundsPence: return "Pounds & pence"
|
case .poundsPence: return "Pounds & pence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +255,8 @@ enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openMap: return "Open map"
|
case .openMap: return "Directions"
|
||||||
case .showDetails: return "More info"
|
case .showDetails: return "Station details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,8 +273,56 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
let lng: Double
|
let lng: Double
|
||||||
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
|
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
|
||||||
let prices: [FuelType: Double]
|
let prices: [FuelType: Double]
|
||||||
/// Updated timestamp (seconds since 1970) — nil for sample data.
|
/// Station-level price timestamp (seconds since 1970): the relay's
|
||||||
|
/// `price_updated`, i.e. the newest price submission across the grades it
|
||||||
|
/// maps — never a per-grade time. nil when the source omits it (sample data).
|
||||||
let priceUpdated: TimeInterval?
|
let priceUpdated: TimeInterval?
|
||||||
|
init(id: String, name: String, brand: String, address: String, postcode: String,
|
||||||
|
lat: Double, lng: Double, prices: [FuelType: Double],
|
||||||
|
priceUpdated: TimeInterval?) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.brand = brand
|
||||||
|
self.address = address
|
||||||
|
self.postcode = postcode
|
||||||
|
self.lat = lat
|
||||||
|
self.lng = lng
|
||||||
|
self.prices = prices
|
||||||
|
self.priceUpdated = priceUpdated
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, brand, address, postcode, lat, lng, prices
|
||||||
|
case priceUpdated = "priceUpdated"
|
||||||
|
case priceUpdatedSnake = "price_updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decode(String.self, forKey: .id)
|
||||||
|
name = try values.decode(String.self, forKey: .name)
|
||||||
|
brand = try values.decode(String.self, forKey: .brand)
|
||||||
|
address = try values.decode(String.self, forKey: .address)
|
||||||
|
postcode = try values.decode(String.self, forKey: .postcode)
|
||||||
|
lat = try values.decode(Double.self, forKey: .lat)
|
||||||
|
lng = try values.decode(Double.self, forKey: .lng)
|
||||||
|
prices = try values.decode([FuelType: Double].self, forKey: .prices)
|
||||||
|
priceUpdated = try values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdated)
|
||||||
|
?? values.decodeIfPresent(TimeInterval.self, forKey: .priceUpdatedSnake)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try values.encode(id, forKey: .id)
|
||||||
|
try values.encode(name, forKey: .name)
|
||||||
|
try values.encode(brand, forKey: .brand)
|
||||||
|
try values.encode(address, forKey: .address)
|
||||||
|
try values.encode(postcode, forKey: .postcode)
|
||||||
|
try values.encode(lat, forKey: .lat)
|
||||||
|
try values.encode(lng, forKey: .lng)
|
||||||
|
try values.encode(prices, forKey: .prices)
|
||||||
|
try values.encodeIfPresent(priceUpdated, forKey: .priceUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cheap haversine distance to a location, in km.
|
/// Cheap haversine distance to a location, in km.
|
||||||
func distanceKM(to lat2: Double, lng2: Double) -> Double {
|
func distanceKM(to lat2: Double, lng2: Double) -> Double {
|
||||||
@@ -653,17 +701,28 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadAlertsMode() -> AlertMode {
|
static func loadAlertsMode() -> AlertMode {
|
||||||
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
guard let raw = loadString(service: alertsModeKey),
|
||||||
return mode
|
let mode = AlertMode(rawValue: raw),
|
||||||
}
|
mode == .cheapest else {
|
||||||
return .cheapest
|
return .cheapest
|
||||||
}
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
static func saveAlertsMode(_ mode: AlertMode) {
|
static func saveAlertsMode(_ mode: AlertMode) {
|
||||||
saveString(mode.rawValue, service: alertsModeKey)
|
saveString(mode.rawValue, service: alertsModeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0]
|
/// The Best Value gate's options, in 50p steps from 50p to £5. The gate is
|
||||||
|
/// the same figure for the listing's BEST VALUE badges and for the alert, so
|
||||||
|
/// its granularity decides how finely the two can be tuned: the old four
|
||||||
|
/// values (50p, £1, £2, £5) made £2.50 and £3 unreachable, and a whole extra
|
||||||
|
/// detour's worth of saving sat between £1 and £2. Multiples of 0.5 are
|
||||||
|
/// exactly representable, so the stored value and the picker's tags cannot
|
||||||
|
/// drift apart.
|
||||||
|
static let bestValueAlertThresholdOptionsGBP: [Double] = [
|
||||||
|
0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0
|
||||||
|
]
|
||||||
|
|
||||||
static func loadAlertsBestValueThresholdGBP() -> Double {
|
static func loadAlertsBestValueThresholdGBP() -> Double {
|
||||||
if let raw = loadString(service: alertsBestValueThresholdKey),
|
if let raw = loadString(service: alertsBestValueThresholdKey),
|
||||||
@@ -793,6 +852,26 @@ struct FuelStore {
|
|||||||
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One field of the Fuel Savings settings. A control that edits a single
|
||||||
|
/// value saves just that field, so moving the MPG slider rewrites the MPG key
|
||||||
|
/// instead of all four — four UserDefaults writes and four keychain round
|
||||||
|
/// trips per gesture is what made those sliders stutter.
|
||||||
|
enum FuelSavingsField {
|
||||||
|
case enabled
|
||||||
|
case mpg
|
||||||
|
case amount
|
||||||
|
case unit
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) {
|
||||||
|
switch field {
|
||||||
|
case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
|
||||||
|
case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey)
|
||||||
|
case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
|
||||||
|
case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -979,7 +1058,7 @@ struct FuelStore {
|
|||||||
|
|
||||||
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
|
/// "Follow search" caps the geofence at 8 miles so a 10/15-mile Stations
|
||||||
/// search never creates huge region-monitoring circles.
|
/// search never creates huge region-monitoring circles.
|
||||||
static let alertFollowCapKM: Double = 8 * 1.60934
|
static let alertFollowCapKM: Double = 8 * FuelSavingsCalculator.kilometresPerMile
|
||||||
|
|
||||||
/// The effective alert radius: the manual radius, or — when alerts follow
|
/// The effective alert radius: the manual radius, or — when alerts follow
|
||||||
/// the Stations-tab search — that distance (miles, converted via the
|
/// the Stations-tab search — that distance (miles, converted via the
|
||||||
@@ -1618,10 +1697,22 @@ struct FuelStore {
|
|||||||
// what the rows show.
|
// what the rows show.
|
||||||
if accurateDistancesEnabled,
|
if accurateDistancesEnabled,
|
||||||
accurateDistancesReady(userLat: userLat, userLng: userLng),
|
accurateDistancesReady(userLat: userLat, userLng: userLng),
|
||||||
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) {
|
||||||
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false)
|
let returnKM = (entry.meters + (entry.returnMeters ?? entry.meters)) / 1000.0
|
||||||
|
return StationDistanceDisplay(
|
||||||
|
km: entry.meters / 1000.0,
|
||||||
|
isApproximate: false,
|
||||||
|
returnKM: returnKM,
|
||||||
|
returnIsApproximate: entry.returnMeters == nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return StationDistanceDisplay(km: station.distanceKM(to: userLat, lng2: userLng), isApproximate: true)
|
let km = station.distanceKM(to: userLat, lng2: userLng)
|
||||||
|
return StationDistanceDisplay(
|
||||||
|
km: km,
|
||||||
|
isApproximate: true,
|
||||||
|
returnKM: km * 2,
|
||||||
|
returnIsApproximate: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Radius test on the SAME distance the UI shows. Filtering straight-line
|
/// Radius test on the SAME distance the UI shows. Filtering straight-line
|
||||||
@@ -1650,11 +1741,18 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func writeKeychain(data: Data, service: String) {
|
private static func writeKeychain(data: Data, service: String) {
|
||||||
let deleteQuery: [String: Any] = [
|
// Update in place when the item already exists. Delete-then-add cost two
|
||||||
|
// round trips to securityd and briefly left the key holding no value at
|
||||||
|
// all, which is half of why a setting could disagree between launches.
|
||||||
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
]
|
]
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
let status = SecItemUpdate(query as CFDictionary,
|
||||||
|
[kSecValueData as String: data] as CFDictionary)
|
||||||
|
guard status == errSecItemNotFound else { return }
|
||||||
|
// Nothing stored under this service yet — the first write after the app
|
||||||
|
// was deleted, or a key added since. Add it.
|
||||||
let addQuery: [String: Any] = [
|
let addQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
@@ -1710,35 +1808,65 @@ struct RoadDistanceDiagnostics: Equatable {
|
|||||||
/// road distance or the straight-line fallback. The confidence travels WITH the
|
/// road distance or the straight-line fallback. The confidence travels WITH the
|
||||||
/// value, so a label can never claim a confidence the figure doesn't have.
|
/// value, so a label can never claim a confidence the figure doesn't have.
|
||||||
struct StationDistanceDisplay: Equatable {
|
struct StationDistanceDisplay: Equatable {
|
||||||
let km: Double
|
let km: Double
|
||||||
let isApproximate: Bool
|
let isApproximate: Bool
|
||||||
|
let returnKM: Double
|
||||||
|
let returnIsApproximate: Bool
|
||||||
|
|
||||||
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
|
init(km: Double,
|
||||||
/// that is NOT a routed road distance can never be read as one, and a cached
|
isApproximate: Bool,
|
||||||
/// road distance stays a bare figure. It updates by itself once routing lands,
|
returnKM: Double? = nil,
|
||||||
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
|
returnIsApproximate: Bool? = nil) {
|
||||||
func label(unit: DistanceUnit) -> String {
|
self.km = km
|
||||||
|
self.isApproximate = isApproximate
|
||||||
|
self.returnKM = returnKM ?? km * 2
|
||||||
|
self.returnIsApproximate = returnIsApproximate ?? isApproximate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
|
||||||
|
/// that is NOT a routed road distance can never be read as one, and a cached
|
||||||
|
/// road distance stays a bare figure. It updates by itself once routing lands,
|
||||||
|
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
|
||||||
|
func label(unit: DistanceUnit) -> String {
|
||||||
let text = unit.format(km)
|
let text = unit.format(km)
|
||||||
return isApproximate ? "Approx. \(text)" : text
|
return isApproximate ? "Approx. \(text)" : text
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row/sheet label that keeps the one-way station distance primary, then
|
/// Row/sheet label that keeps the one-way station distance primary, then
|
||||||
/// adds the out-and-back distance in brackets. This matches Best Value's
|
/// adds the independently measured or estimated out-and-back distance.
|
||||||
/// "extra return travel" basis without changing the actual distance model.
|
func labelWithReturn(unit: DistanceUnit) -> String {
|
||||||
func labelWithReturn(unit: DistanceUnit) -> String {
|
"\(label(unit: unit)) (\(returnLabel(unit: unit)))"
|
||||||
"\(label(unit: unit)) (\(unit.format(km * 2)) return)"
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// VoiceOver form of `label(unit:)`.
|
/// The out-and-back figure on its own: "5.0 mi return" / "Approx. 5.0 mi return".
|
||||||
func spokenLabel(unit: DistanceUnit) -> String {
|
///
|
||||||
|
/// Split out of `labelWithReturn` for surfaces that print the one-way and the
|
||||||
|
/// return figure on SEPARATE lines (the More info sheet's distance column). Both
|
||||||
|
/// figures still come from this one type, so a routed value can never be marked
|
||||||
|
/// "Approx." while its return figure is not — or the other way round.
|
||||||
|
func returnLabel(unit: DistanceUnit) -> String {
|
||||||
|
let text = unit.format(returnKM)
|
||||||
|
return returnIsApproximate ? "Approx. \(text) return" : "\(text) return"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `returnLabel(unit:)`.
|
||||||
|
func spokenReturnLabel(unit: DistanceUnit) -> String {
|
||||||
|
let text = unit.spoken(returnKM)
|
||||||
|
return returnIsApproximate ? "Approximately \(text) return" : "\(text) return"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `label(unit:)`.
|
||||||
|
func spokenLabel(unit: DistanceUnit) -> String {
|
||||||
let text = unit.spoken(km)
|
let text = unit.spoken(km)
|
||||||
return isApproximate ? "Approximately \(text)" : text
|
return isApproximate ? "Approximately \(text)" : text
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VoiceOver form of `labelWithReturn(unit:)`.
|
/// VoiceOver form of `labelWithReturn(unit:)`.
|
||||||
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
|
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
|
||||||
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return"
|
let returnText = unit.spoken(returnKM)
|
||||||
}
|
let returnLabel = returnIsApproximate ? "Approximately \(returnText)" : returnText
|
||||||
|
return "\(spokenLabel(unit: unit)), \(returnLabel) return"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached Apple-Maps road distances for nearby stations (see
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ enum SiriCheapestLookup {
|
|||||||
lng: Double,
|
lng: Double,
|
||||||
withinMiles: Double
|
withinMiles: Double
|
||||||
) -> FuelStation? {
|
) -> FuelStation? {
|
||||||
let radiusKM = withinMiles * 1.60934
|
let radiusKM = withinMiles * FuelSavingsCalculator.kilometresPerMile
|
||||||
// Same radius rule as the app list: the distance the answer would SHOW
|
// Same radius rule as the app list: the distance the answer would SHOW
|
||||||
// (cached road route where there is one).
|
// (cached road route where there is one).
|
||||||
return stations
|
return stations
|
||||||
|
|||||||
Reference in New Issue
Block a user