Compare commits
37
Commits
39a97f4905
..
main
+18
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
scene (CPListTemplate) for navigation deep-links.
|
||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -599,7 +599,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -663,7 +663,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -728,7 +728,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -826,7 +826,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -851,7 +851,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -875,7 +875,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -900,7 +900,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
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
|
||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||
/// Settings → Debug; the user-facing page keeps a plain-language explainer
|
||||
/// plus a 0-stations hint.
|
||||
/// Settings → Debug; the user-facing page keeps short descriptions of the
|
||||
/// cheapest-station rule and radius, plus a 0-stations hint.
|
||||
struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||
@Binding var alertMode: AlertMode
|
||||
@Binding var bestValueThresholdGBP: Double
|
||||
let bestValueConfigured: Bool
|
||||
@Binding var favouriteDropEnabled: Bool
|
||||
@Binding var favouriteDropFuel: FuelType
|
||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||
@@ -52,31 +49,29 @@ struct AlertsView: View {
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// Situational guidance for the currently-selected alert radius, shown
|
||||
/// under the picker so users know what each choice is best for. Labels
|
||||
/// mirror the picker row text (unit-aware) and explain the trade-off:
|
||||
/// smaller radii ping more often but later; larger radii ping rarely in
|
||||
/// town but earlier from afar.
|
||||
private var alertRadiusGuidance: String {
|
||||
/// The distance the geofence actually caps "Follow search" at, in miles
|
||||
/// (FuelStore stores the cap in km so the monitor and storage are unit-free).
|
||||
private var followSearchCapMiles: Int {
|
||||
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||
}
|
||||
|
||||
/// The radius the selected option really monitors, stated in the user's
|
||||
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
||||
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
||||
/// geofence caps; said plainly, and only when the search distance really
|
||||
/// exceeds the cap, so the sentence never contradicts the picker row.
|
||||
private var alertRadiusDescription: String {
|
||||
switch alertChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
|
||||
case .fixed(let value):
|
||||
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
let body: String
|
||||
switch value {
|
||||
case 1:
|
||||
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
|
||||
case 2:
|
||||
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
|
||||
case 3:
|
||||
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
|
||||
case 5:
|
||||
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
|
||||
default:
|
||||
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
|
||||
let capped = stationLimit > followSearchCapMiles
|
||||
let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
|
||||
if capped {
|
||||
return String(format: String(localized: "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).", comment: "Proximity alert radius description, follow search capped"), label)
|
||||
}
|
||||
return String(format: String(localized: "%@ — %@", 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 {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
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) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||
.disabled(!favouriteDropEnabled)
|
||||
Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
|
||||
} header: {
|
||||
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
||||
} footer: {
|
||||
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
|
||||
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
if !bestValueConfigured {
|
||||
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
|
||||
.font(.caption)
|
||||
Group {
|
||||
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
|
||||
.font(.footnote)
|
||||
.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) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
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) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||
}
|
||||
}
|
||||
Text(alertRadiusGuidance)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(alertRadiusDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if alertMode == .bestValue {
|
||||
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
|
||||
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
||||
Text(FuelSavingsFormat.money(value)).tag(value)
|
||||
}
|
||||
}
|
||||
if !bestValueConfigured {
|
||||
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if enabled && monitoredCount == 0 {
|
||||
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
||||
.font(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fuelBoardDisabledState(isEnabled: enabled)
|
||||
} header: {
|
||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||
} footer: {
|
||||
let modeDescription = alertMode == .bestValue
|
||||
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
|
||||
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
|
||||
Text(modeDescription)
|
||||
}
|
||||
|
||||
if enabled, let lastAlert {
|
||||
@@ -212,21 +243,38 @@ struct AlertsView: View {
|
||||
|
||||
Section {
|
||||
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) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
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) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||
}
|
||||
}
|
||||
}
|
||||
if liveActivityFollowsSearch {
|
||||
Text(String(localized: "Uses the same distance as your station search.", comment: "Live Activity distance description, follow search"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
|
||||
} header: {
|
||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||
} footer: {
|
||||
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
|
||||
|
||||
@@ -83,7 +83,7 @@ struct BestValueIntroContent: View {
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
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,
|
||||
value: amountBinding,
|
||||
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One semantic capsule tone: a foreground and a background for each appearance.
|
||||
///
|
||||
/// The palette is deliberately a TINTED pair rather than a saturated solid. Every
|
||||
/// tone below clears 4.5:1 for its own foreground on its own background in BOTH
|
||||
/// appearances (measured, 4.96:1 to 6.72:1), which is what lets one component
|
||||
/// carry the same meaning on a white row and a dark card without a second
|
||||
/// colour per status.
|
||||
///
|
||||
/// The pairs are resolved through `UIColor`'s dynamic provider rather than
|
||||
/// SwiftUI's `@Environment(\.colorScheme)`: a capsule inside a `List` is often
|
||||
/// re-rendered without its body being rebuilt, so a scheme read at body time can
|
||||
/// lag one switch behind. This way the colour itself follows the appearance.
|
||||
struct CapsuleTone: Equatable {
|
||||
let lightForeground: Color
|
||||
let lightBackground: Color
|
||||
let darkForeground: Color
|
||||
let darkBackground: Color
|
||||
|
||||
var foreground: Color { dynamic(light: lightForeground, dark: darkForeground) }
|
||||
var background: Color { dynamic(light: lightBackground, dark: darkBackground) }
|
||||
|
||||
private func dynamic(light: Color, dark: Color) -> Color {
|
||||
let lightUI = UIColor(light)
|
||||
let darkUI = UIColor(dark)
|
||||
return Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? darkUI : lightUI
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension CapsuleTone {
|
||||
/// Green: a financial saving.
|
||||
static let saves = CapsuleTone(
|
||||
lightForeground: Color(hex: 0x176B3D), lightBackground: Color(hex: 0xDDF3E5),
|
||||
darkForeground: Color(hex: 0x66D69A), darkBackground: Color(hex: 0x204333)
|
||||
)
|
||||
|
||||
/// Grey: a neutral outcome, neither saving nor costing.
|
||||
static let breakEven = CapsuleTone(
|
||||
lightForeground: Color(hex: 0x555B64), lightBackground: Color(hex: 0xE9EAED),
|
||||
darkForeground: Color(hex: 0xD0D0D5), darkBackground: Color(hex: 0x404044)
|
||||
)
|
||||
|
||||
/// Red: an additional cost.
|
||||
static let costs = CapsuleTone(
|
||||
lightForeground: Color(hex: 0xA63F3D), lightBackground: Color(hex: 0xF9E1DF),
|
||||
darkForeground: Color(hex: 0xEF9690), darkBackground: Color(hex: 0x4B3032)
|
||||
)
|
||||
|
||||
/// Purple: best overall value after travel.
|
||||
static let bestValue = CapsuleTone(
|
||||
lightForeground: Color(hex: 0x7043A1), lightBackground: Color(hex: 0xEEE4F8),
|
||||
darkForeground: Color(hex: 0xD1B4FA), darkBackground: Color(hex: 0x44354F)
|
||||
)
|
||||
|
||||
/// Blue: the lowest fuel price in range.
|
||||
static let cheapest = CapsuleTone(
|
||||
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
|
||||
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
|
||||
)
|
||||
|
||||
/// Neutral grey: proximity. Same family as `breakEven` — both mean "no
|
||||
/// judgement" — but kept as its own pair so proximity and a break-even
|
||||
/// verdict stay independently tunable.
|
||||
static let closest = CapsuleTone(
|
||||
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
|
||||
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
|
||||
)
|
||||
}
|
||||
|
||||
/// The one shape rule every capsule in the app follows.
|
||||
///
|
||||
/// A capsule while the content is a single line; a rounded rectangle once it
|
||||
/// wraps. A pill's semicircular ends crowd the text corners at two lines or
|
||||
/// more, while a 10pt radius does not. The shape follows what the content
|
||||
/// actually DREW, not how many lines it declared, because a label can wrap in a
|
||||
/// narrow column without the data changing — the baseline label was the case that
|
||||
/// forced this: "Closest station" was one declared line that wrapped to two in the
|
||||
/// listing's price column, which is also why that label is now the single word
|
||||
/// "Closest".
|
||||
///
|
||||
/// Measuring happens on the content BEFORE padding, so the drawn line count is
|
||||
/// never inflated by the capsule's own insets.
|
||||
struct AdaptiveCapsule<Content: View>: View {
|
||||
/// The tone's background fill. The caller resolves it from its own semantics.
|
||||
let fill: Color
|
||||
/// One line's height for the content's font, already scaled with Dynamic
|
||||
/// Type — pass a `@ScaledMetric` so the division below tracks the user's size.
|
||||
let lineHeight: CGFloat
|
||||
/// Lines the content declares, used for the first layout pass before a
|
||||
/// measured height exists.
|
||||
var declaredLineCount: Int = 1
|
||||
var cornerRadius: CGFloat = 10
|
||||
var horizontalPadding: CGFloat
|
||||
var verticalPadding: CGFloat
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
@State private var drawnHeight: CGFloat = 0
|
||||
|
||||
private var lineCount: Int {
|
||||
guard drawnHeight > 0, lineHeight > 0 else { return max(1, declaredLineCount) }
|
||||
return max(1, Int((drawnHeight / lineHeight).rounded()))
|
||||
}
|
||||
|
||||
private var shape: AnyShape {
|
||||
lineCount > 1
|
||||
? AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
: AnyShape(Capsule())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(key: CapsuleContentHeightKey.self,
|
||||
value: proxy.size.height)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
.background(shape.fill(fill))
|
||||
.onPreferenceChange(CapsuleContentHeightKey.self) { drawnHeight = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the drawn height of a capsule's content up to `lineCount`.
|
||||
private struct CapsuleContentHeightKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// A fixed sRGB colour from a 24-bit literal. The capsule palette is
|
||||
/// appearance-specific and supplied per tone, so these are deliberately NOT
|
||||
/// dynamic — `CapsuleTone` resolves the pair itself.
|
||||
init(hex: UInt32) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays its children left to right and wraps to a new line when the next one
|
||||
/// would overflow, so two or three capsules never squeeze or truncate each other
|
||||
/// on a narrow screen or at large Dynamic Type.
|
||||
///
|
||||
/// A flow layout rather than `ViewThatFits`: a fallback would stack the capsules
|
||||
/// vertically as soon as one did not fit, which reads as three separate labels
|
||||
/// rather than one row of rankings. This keeps them on a line while they fit and
|
||||
/// only moves the overflowing one down.
|
||||
///
|
||||
/// Children are measured with `.unspecified` width so a long label takes the width
|
||||
/// it needs (the badge sets `fixedSize` horizontally) instead of being clipped to
|
||||
/// the container.
|
||||
struct CapsuleFlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
var lineSpacing: CGFloat = 6
|
||||
|
||||
private struct Row {
|
||||
var range: Range<Int>
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
let rows = rows(maxWidth: maxWidth, subviews: subviews)
|
||||
let width = rows.map(\.width).max() ?? 0
|
||||
let height = rows.reduce(0) { $0 + $1.height }
|
||||
+ CGFloat(max(0, rows.count - 1)) * lineSpacing
|
||||
return CGSize(width: min(width, maxWidth), height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var y = bounds.minY
|
||||
for row in rows(maxWidth: bounds.width, subviews: subviews) {
|
||||
var x = bounds.minX
|
||||
for index in row.range {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y),
|
||||
anchor: .topLeading,
|
||||
proposal: ProposedViewSize(size)
|
||||
)
|
||||
x += size.width + spacing
|
||||
}
|
||||
y += row.height + lineSpacing
|
||||
}
|
||||
}
|
||||
|
||||
private func rows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
|
||||
var rows: [Row] = []
|
||||
var start = 0
|
||||
var used: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
for index in subviews.indices {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
let needed = used == 0 ? size.width : used + spacing + size.width
|
||||
// `used > 0` guards the first child: a single capsule wider than the
|
||||
// container must still be placed (and may wrap its own text) rather
|
||||
// than starting an empty line above itself.
|
||||
if used > 0, needed > maxWidth {
|
||||
rows.append(Row(range: start..<index, width: used, height: height))
|
||||
start = index
|
||||
used = size.width
|
||||
height = size.height
|
||||
} else {
|
||||
used = needed
|
||||
height = max(height, size.height)
|
||||
}
|
||||
}
|
||||
if start < subviews.count {
|
||||
rows.append(Row(range: start..<subviews.count, width: used, height: height))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
/// The More info sheet's row of ranking capsules: every label the station
|
||||
/// qualifies for, wrapped when the screen is narrow.
|
||||
///
|
||||
/// Renders as the SAME `StationLabelBadge` the listing rows use — same tone, same
|
||||
/// symbol, same sentence-case copy — so a station carrying "Best value" here and
|
||||
/// on the Stations list cannot look like two different things.
|
||||
struct StationRankingCapsules: View {
|
||||
let kinds: [StationLabelKind]
|
||||
|
||||
var body: some View {
|
||||
CapsuleFlowLayout(spacing: 6, lineSpacing: 6) {
|
||||
ForEach(kinds, id: \.self) { kind in
|
||||
StationLabelBadge(kind: kind)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// One phrase per capsule, read in the order they are shown.
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
+312
-141
@@ -29,15 +29,14 @@ struct ContentView: View {
|
||||
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
|
||||
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
|
||||
@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 fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
|
||||
@State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
|
||||
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
|
||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
||||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||
@@ -114,6 +113,21 @@ struct ContentView: View {
|
||||
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 {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
@@ -137,31 +151,42 @@ struct ContentView: View {
|
||||
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) {
|
||||
monitor.update(
|
||||
stations: stations,
|
||||
favourites: refreshedFavourites,
|
||||
fuel: overrideFuel ?? alertsFuel,
|
||||
radiusKM: effectiveAlertsRadiusKM,
|
||||
alertMode: effectiveAlertsMode,
|
||||
alertMode: .cheapest,
|
||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||
fuelSavingsSettings: fuelSavingsSettings,
|
||||
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() {
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
locationManager.onLocationUpdate = {
|
||||
self.updateAlertMonitor()
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
@@ -296,29 +321,29 @@ struct ContentView: View {
|
||||
return pool.compactMap { $0.prices[selectedFuel] }.min()
|
||||
}
|
||||
|
||||
/// The "best" station — the cheapest in the pool — gets the TOP badge.
|
||||
/// The cheapest station in the pool — the one that wears the Cheapest badge.
|
||||
/// First occurrence in display order wins on price ties.
|
||||
private var topStationID: String? {
|
||||
private var cheapestStationID: String? {
|
||||
guard let baselinePrice else { return nil }
|
||||
return topStationID(in: displayedStations, baselinePrice: baselinePrice)
|
||||
return cheapestStationID(in: displayedStations, baselinePrice: baselinePrice)
|
||||
}
|
||||
|
||||
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller
|
||||
/// that has the sorted list (the Stations tab) does not sort the whole
|
||||
/// The Cheapest-badge station within an ALREADY-SORTED list. Split out so a
|
||||
/// caller that has the sorted list (the Stations tab) does not sort the whole
|
||||
/// country a second time just to find one id.
|
||||
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
|
||||
private func cheapestStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
|
||||
list.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
||||
}
|
||||
|
||||
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
|
||||
/// The app's own "cheapest in range" — the Cheapest-badge station — mirrored
|
||||
/// into the Settings → Debug section so its "Cheapest in range" row
|
||||
/// matches the app list (and the widget when the widget's fuel + distance
|
||||
/// match the app's). The debug section previously showed the ALERT
|
||||
/// prediction (alertsFuel + alert radius), which is a different pool by
|
||||
/// design — that mismatch is what this fixes.
|
||||
private var appCheapestStatus: DebugAppCheapest? {
|
||||
guard let topID = topStationID,
|
||||
let station = stations.first(where: { $0.id == topID }),
|
||||
guard let cheapestID = cheapestStationID,
|
||||
let station = stations.first(where: { $0.id == cheapestID }),
|
||||
let location else { return nil }
|
||||
return DebugAppCheapest(
|
||||
station: station,
|
||||
@@ -401,28 +426,27 @@ struct ContentView: View {
|
||||
/// (computing a journey for each) and `savingsLookup` immediately scanned them
|
||||
/// all AGAIN to fill the per-station map. Both now share one result.
|
||||
///
|
||||
/// The baseline is chosen with EXACTLY the loop and guards of
|
||||
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest
|
||||
/// station" cannot move — and it is still selected when the feature is
|
||||
/// unconfigured (it names that row), while the maps are only built when the
|
||||
/// labels will actually be shown.
|
||||
/// The baseline is chosen with the same journey guards as
|
||||
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
|
||||
/// off, the ordinary closest station still names that row.
|
||||
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
|
||||
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 bestStation: FuelStation?
|
||||
var bestMiles = Double.infinity
|
||||
for station in stations where station.prices[selectedFuel] != nil {
|
||||
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue }
|
||||
if configured { journeys[station.id] = journey }
|
||||
let miles = journey.miles
|
||||
guard miles.isFinite, miles >= 0 else { continue }
|
||||
if miles < bestMiles {
|
||||
bestMiles = miles
|
||||
bestStation = station
|
||||
if let journey = savingsJourneyMiles(for: station, location: location) {
|
||||
journeys[station.id] = journey
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -800,11 +824,19 @@ struct ContentView: View {
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceStyle: priceDisplayStyle,
|
||||
fuelSavings: savingsLookup(baseline: scaffold.baseline,
|
||||
journeys: scaffold.journeys)(station),
|
||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||
savingsBaselineStationName: scaffold.baseline?.name,
|
||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
||||
// The same "cheapest in range" test this view gives its
|
||||
// rows, so the sheet's Cheapest capsule matches the badge on
|
||||
// the list it was opened from.
|
||||
isCheapestInRange: station.id == cheapestStationID,
|
||||
// The same "closest" test the rows get, from the ONE
|
||||
// scaffold baseline the savings lookup also compares against.
|
||||
isClosestInRange: station.id == scaffold.baseline?.id,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
||||
)
|
||||
@@ -829,6 +861,15 @@ struct ContentView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
|
||||
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
|
||||
let info = note.userInfo ?? [:]
|
||||
if let id = info["stationID"] as? String,
|
||||
@@ -1013,9 +1054,10 @@ struct ContentView: View {
|
||||
sortMode = .worthTheDrive
|
||||
}
|
||||
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
|
||||
// "Tap station = More info". The stored default is open-Apple-Maps,
|
||||
// so on a simulator a tap bounces out to Maps with no way back and
|
||||
// the Worth the Drive detail section can't be checked at all.
|
||||
// "Tap station = More info". The default is already the detail
|
||||
// sheet, so this only matters on a simulator whose store has
|
||||
// "Directions" saved — it overrides the stored choice either way,
|
||||
// which keeps the Worth the Drive detail section reachable.
|
||||
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
|
||||
stationTapAction = .showDetails
|
||||
}
|
||||
@@ -1109,7 +1151,6 @@ struct ContentView: View {
|
||||
// or the choice wouldn't take effect until the next launch.
|
||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||
ensureBestValueAlertModeIsAvailable()
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
@@ -1119,12 +1160,16 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.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
|
||||
// effective sort follows automatically (no stored sort is touched).
|
||||
#if DEBUG
|
||||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||
#endif
|
||||
ensureBestValueAlertModeIsAvailable()
|
||||
updateAlertMonitor()
|
||||
}
|
||||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||
@@ -1207,16 +1252,6 @@ struct ContentView: View {
|
||||
FuelStore.saveAlertsFuel(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
|
||||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||||
updateAlertMonitor()
|
||||
@@ -1497,15 +1532,15 @@ struct ContentView: View {
|
||||
/// within the compiler's type-check budget.
|
||||
private var stationsTab: some View {
|
||||
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its
|
||||
// count, the TOP badge and every savings label. Asking for
|
||||
// count, the Cheapest badge and every savings label. Asking for
|
||||
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
|
||||
// `topStationID` and `savingsLookup` separately walked the full ~8k list
|
||||
// six times per render — seconds of main-thread work in Closest mode,
|
||||
// `cheapestStationID` and `savingsLookup` separately walked the full ~8k
|
||||
// list six times per render — seconds of main-thread work in Closest mode,
|
||||
// where the pool is every station in the UK.
|
||||
let scaffold = savingsScaffold()
|
||||
let baselineStation = scaffold.baseline
|
||||
let displayed = displayedStations
|
||||
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) }
|
||||
let cheapestID = baselinePrice.flatMap { cheapestStationID(in: displayed, baselinePrice: $0) }
|
||||
return StationsView(
|
||||
stations: displayed,
|
||||
totalCount: displayed.count,
|
||||
@@ -1516,7 +1551,8 @@ struct ContentView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: baselinePrice,
|
||||
topStationID: topID,
|
||||
cheapestStationID: cheapestID,
|
||||
closestStationID: baselineStation?.id,
|
||||
location: location,
|
||||
favouriteIDs: favouriteIDs,
|
||||
stationTapAction: stationTapAction,
|
||||
@@ -1559,9 +1595,6 @@ struct ContentView: View {
|
||||
enabled: $alertsEnabled,
|
||||
radius: $alertsRadius,
|
||||
fuel: $alertsFuel,
|
||||
alertMode: $alertsMode,
|
||||
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
|
||||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||
followsSearch: $alertsFollowsSearch,
|
||||
@@ -1588,6 +1621,7 @@ struct ContentView: View {
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
stationTapAction: $stationTapAction,
|
||||
fuelSavingsSettings: $fuelSavingsSettings,
|
||||
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
@@ -1771,20 +1805,45 @@ struct StationRow: View {
|
||||
let distanceUnit: DistanceUnit
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
let baselinePrice: Double?
|
||||
let isTopResult: Bool
|
||||
/// This row is the cheapest price in range — the caller's existing
|
||||
/// `cheapestStationID` test. Feeds the Cheapest badge.
|
||||
let isCheapestResult: Bool
|
||||
/// This row is the station savings are measured against — the caller's
|
||||
/// `closestStationID` test. Feeds the Closest badge.
|
||||
let isClosestResult: 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 fuelSavings: FuelSavingsAvailability?
|
||||
/// The session fill-up figure (the one the adjustment control edits). The
|
||||
/// compact banner does not print it today, but a row that carried a hard
|
||||
/// zero would print "for £0" the moment it did — pass the real figure.
|
||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
/// The user's Best Value alert threshold — the app's existing gate for
|
||||
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
|
||||
/// exactly the rule the alerts already use. Read per row (the alert path
|
||||
/// reads it per evaluation too); pass explicitly to inject a value.
|
||||
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
var onToggleFavourite: () -> Void = {}
|
||||
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 {
|
||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
|
||||
@@ -1810,6 +1869,27 @@ struct StationRow: View {
|
||||
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, which outranks CLOSEST —
|
||||
/// see `StationRecommendationBadge.badge` for why proximity ranks last.
|
||||
private var recommendationBadge: StationRecommendationBadge? {
|
||||
StationRecommendationBadge.badge(
|
||||
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
||||
isCheapest: isCheapestResult,
|
||||
isClosest: isClosestResult,
|
||||
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() {
|
||||
switch tapAction {
|
||||
case .openMap:
|
||||
@@ -1826,9 +1906,31 @@ struct StationRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||
// Two sections, one hairline: station identity on the left, price and
|
||||
// savings on the right. No wrapper VStack and no full-width verdict
|
||||
// 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 {
|
||||
if let asset = station.brandImageName {
|
||||
Image(asset)
|
||||
@@ -1847,13 +1949,25 @@ struct StationRow: View {
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 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) {
|
||||
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)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.lineLimit(3)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ForEach(StationNotice.notices(for: station)) { notice in
|
||||
Image(systemName: notice.symbolName)
|
||||
.font(.caption2.weight(.semibold))
|
||||
@@ -1861,96 +1975,153 @@ struct StationRow: View {
|
||||
.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,
|
||||
postcode: station.postcode,
|
||||
name: station.name))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let location {
|
||||
let distance = FuelStore.stationDistanceDisplay(
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.accessibilityLabel(distance.spokenLabelWithReturn(unit: distanceUnit))
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
|
||||
}
|
||||
}
|
||||
.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] {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(ragColor)
|
||||
.frame(width: 8, height: 8)
|
||||
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)
|
||||
PriceRatingDot(color: ragColor)
|
||||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
||||
size: priceSize, weight: .bold)
|
||||
.lineLimit(1)
|
||||
.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))
|
||||
}
|
||||
// The closest station wears the Closest badge above its price
|
||||
// and gets no verdict capsule: the capsule's only word for that
|
||||
// state was "Closest", so printing it here as well would say
|
||||
// the same thing twice, three lines apart.
|
||||
if let result = fuelSavings?.result, !isClosestResult {
|
||||
savingsCapsule(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FAR RIGHT — favourite star
|
||||
Button {
|
||||
onToggleFavourite()
|
||||
} 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,
|
||||
compact: true,
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// The spec's ~20-24pt of air between the divider and the price
|
||||
// column: the row's own 12pt spacing plus this 10pt.
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: handleTap)
|
||||
.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" pill carries the same fill, 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 capsule beneath, which is
|
||||
/// filled: a badge annotates the price rather than reporting an outcome, so it
|
||||
/// stays quieter than the pill below it, and tinted text keeps its contrast in
|
||||
/// both appearances at every Dynamic Type size.
|
||||
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||||
StationLabelBadge(kind: badge.labelKind)
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
||||
/// they stay out of the shared model the tests cover.
|
||||
private extension StationRecommendationBadge {
|
||||
/// The shared label kind, so a row's badge and the Key's legend for the same
|
||||
/// meaning can never drift apart.
|
||||
var labelKind: StationLabelKind {
|
||||
switch self {
|
||||
case .bestValue: return .bestValue
|
||||
case .cheapest: return .cheapest
|
||||
case .closest: return .closest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location manager
|
||||
|
||||
@@ -21,7 +21,7 @@ struct FavouritesView: View {
|
||||
/// still never touches the Settings default.
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
/// Estimated saving for a favourite, against the closest eligible station by
|
||||
/// Estimated saving for a favourite, against the closest station by
|
||||
/// journey distance — the same baseline and definition the Stations list uses.
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
@@ -48,6 +48,14 @@ struct FavouritesView: View {
|
||||
/// Trends sheet (price history chart) presentation state.
|
||||
@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).
|
||||
@State private var detailStation: FuelStation?
|
||||
|
||||
@@ -69,6 +77,35 @@ struct FavouritesView: View {
|
||||
}
|
||||
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
||||
|
||||
/// Nearest favourited station for the active fuel — this tab's Closest.
|
||||
///
|
||||
/// Measured on the same one-way distance the rows print, so the badge cannot
|
||||
/// point at a station the list shows as further away than its neighbour. Only
|
||||
/// favourites with a price for the active fuel are eligible, matching the
|
||||
/// radius tab, where the closest station is by definition the savings
|
||||
/// baseline; an unpriced favourite appears last and wears no label. Ties keep
|
||||
/// the user's own order, which is what `ordered` already encodes.
|
||||
private var nearest: FuelStation? {
|
||||
guard let location else { return nil }
|
||||
var best: (station: FuelStation, km: Double)?
|
||||
for station in ordered where station.prices[activeFuel] != nil {
|
||||
let km = FuelStore.stationDistanceDisplay(
|
||||
station: station, userLat: location.lat, userLng: location.lng
|
||||
).km
|
||||
if best == nil || km < best!.km { best = (station, km) }
|
||||
}
|
||||
return best?.station
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private var activeFuelFavouriteIDs: Set<String> {
|
||||
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
||||
@@ -144,12 +181,16 @@ struct FavouritesView: View {
|
||||
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
Section {
|
||||
FuelSavingsAdjustmentView(
|
||||
amount: $fuelSavingsAmount,
|
||||
referencePricePencePerLitre: cheapestPrice
|
||||
)
|
||||
} footer: {
|
||||
Text(String(localized: "Savings below are estimated against the closest eligible station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||
}
|
||||
}
|
||||
|
||||
if showsSavingsComparisonExplanation {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,12 +203,11 @@ struct FavouritesView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: cheapestPrice,
|
||||
isTopResult: station.id == cheapest?.id,
|
||||
isCheapestResult: station.id == cheapest?.id,
|
||||
isClosestResult: station.id == nearest?.id,
|
||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||
topBadgeText: "CHEAPEST",
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
@@ -176,8 +216,13 @@ struct FavouritesView: View {
|
||||
} header: {
|
||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||
} 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"))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let cheapest = cheapest {
|
||||
Section {
|
||||
@@ -190,8 +235,8 @@ struct FavouritesView: View {
|
||||
}
|
||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||
.toolbar {
|
||||
if !favourites.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if !favourites.isEmpty {
|
||||
Button {
|
||||
showTrends = true
|
||||
} label: {
|
||||
@@ -200,7 +245,23 @@ struct FavouritesView: View {
|
||||
}
|
||||
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(
|
||||
showBestValueInfo: $showBestValueInfo,
|
||||
showImportantInformation: $showImportantInformation
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showTrends) {
|
||||
TrendsView(
|
||||
@@ -216,15 +277,28 @@ struct FavouritesView: View {
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceStyle: priceDisplayStyle,
|
||||
selectedFuel: activeFuel,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// This tab's cheapest favourite — the same flag its rows get,
|
||||
// and the same label they wear.
|
||||
isCheapestInRange: station.id == cheapest?.id,
|
||||
// This tab's nearest favourite — the same flag its rows get.
|
||||
isClosestInRange: station.id == nearest?.id,
|
||||
// Every station listed here IS a favourite for the tab's
|
||||
// fuel, so the star starts filled; tapping it unstars.
|
||||
isFavourite: true,
|
||||
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 {
|
||||
#if DEBUG
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+172
-202
@@ -2,7 +2,8 @@ import SwiftUI
|
||||
|
||||
struct FuelSavingsAdjustmentView: View {
|
||||
@Binding var amount: FuelSavingsFillAmount
|
||||
let referencePricePencePerLitre: Double?
|
||||
|
||||
@State private var showingComparisonHelp = false
|
||||
|
||||
private var range: ClosedRange<Double> {
|
||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||
@@ -25,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("How much are you filling up?")
|
||||
HStack {
|
||||
Text("How much fuel?")
|
||||
.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) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
@@ -41,7 +67,7 @@ struct FuelSavingsAdjustmentView: View {
|
||||
.monospacedDigit()
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -52,12 +78,9 @@ struct FuelSavingsAdjustmentView: View {
|
||||
get: { amount.unit },
|
||||
set: { newUnit in
|
||||
guard newUnit != amount.unit else { return }
|
||||
if let referencePricePencePerLitre,
|
||||
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
||||
amount = converted
|
||||
} else {
|
||||
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
|
||||
}
|
||||
// No one litre figure equals a budget at every candidate price,
|
||||
// so switching modes starts from that mode's configured default.
|
||||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -70,215 +93,162 @@ struct FuelSavingsAdjustmentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct FuelSavingsSummaryView: View {
|
||||
let availability: FuelSavingsAvailability?
|
||||
let amount: FuelSavingsFillAmount
|
||||
var baselineStationName: String? = nil
|
||||
var compact: Bool = false
|
||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
||||
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
||||
var distanceUnit: DistanceUnit = .miles
|
||||
var showInfoAction: (() -> Void)? = nil
|
||||
|
||||
private func comparisonLabel(for result: FuelSavingsResult) -> String {
|
||||
FuelSavingsFormat.comparisonLabel(status: result.status)
|
||||
}
|
||||
|
||||
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) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
capsule(for: result, compact: true)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(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) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
capsule(for: result, compact: false)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||
}
|
||||
LabeledContent("Your fill-up", value: amountText)
|
||||
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
||||
.monospacedDigit()
|
||||
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 eligible station by journey distance is the baseline — every other saving is measured against it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Compared with \(baselineStationName ?? "your closest station").")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
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"
|
||||
)
|
||||
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||
Divider()
|
||||
LabeledContent {
|
||||
Text(result.headline())
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(capsuleColor(for: result.status))
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("Total")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if let showInfoAction {
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: showInfoAction) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
/// The colour-coded monetary verdict capsule used by the detail sheet and other
|
||||
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
|
||||
///
|
||||
/// 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 {
|
||||
/// 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.
|
||||
struct FuelSavingsCapsule: View {
|
||||
let result: FuelSavingsResult
|
||||
var compact: Bool = true
|
||||
|
||||
/// One line of `.headline` / `.caption`, scaled with Dynamic Type, so the
|
||||
/// shared shape rule can tell one drawn line from two.
|
||||
@ScaledMetric(relativeTo: .headline) private var headlineLineHeight: CGFloat = 22
|
||||
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
|
||||
|
||||
private var tone: CapsuleTone { Self.tone(for: result.status) }
|
||||
|
||||
var body: some View {
|
||||
AdaptiveCapsule(
|
||||
fill: tone.background,
|
||||
lineHeight: compact ? captionLineHeight : headlineLineHeight,
|
||||
horizontalPadding: compact ? 8 : 12,
|
||||
verticalPadding: compact ? 4 : 7
|
||||
) {
|
||||
HStack(spacing: compact ? 3 : 5) {
|
||||
Image(systemName: symbol(for: result.status))
|
||||
Image(systemName: Self.symbol(for: result.status))
|
||||
.accessibilityHidden(true)
|
||||
Text(result.capsuleHeadline())
|
||||
.lineLimit(1)
|
||||
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||
// The detail sheet's capsule sits beside a headline and has the
|
||||
// width for its wording; the listing's is narrow and scales.
|
||||
.lineLimit(compact ? 1 : nil)
|
||||
.minimumScaleFactor(compact ? 0.7 : 1)
|
||||
}
|
||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||
.foregroundStyle(capsuleColor(for: result.status))
|
||||
.padding(.horizontal, compact ? 8 : 12)
|
||||
.padding(.vertical, compact ? 4 : 7)
|
||||
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
|
||||
.foregroundStyle(tone.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
||||
/// baseline — the same green/amber/red family the price dot already uses.
|
||||
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
|
||||
/// Verdict tone: green save, red worse off, grey break even, neutral-grey
|
||||
/// baseline — the same meanings the price dot's RAG family carries.
|
||||
static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
|
||||
switch status {
|
||||
case .worthTheDrive: return .green
|
||||
case .notWorthTheDrive: return .red
|
||||
case .breakEven: return .secondary
|
||||
case .baseline: return .blue
|
||||
case .worthTheDrive: return .saves
|
||||
case .notWorthTheDrive: return .costs
|
||||
case .breakEven: return .breakEven
|
||||
case .baseline: return .closest
|
||||
}
|
||||
}
|
||||
|
||||
private func symbol(for status: FuelSavingsStatus) -> String {
|
||||
/// Foreground-only form, for a `Text` that is tinted rather than filled (the
|
||||
/// detail sheet's Total row). Keeps one source for the verdict colour.
|
||||
static func color(for status: FuelSavingsStatus) -> Color {
|
||||
tone(for: status).foreground
|
||||
}
|
||||
|
||||
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||
switch status {
|
||||
case .worthTheDrive: return "checkmark.circle.fill"
|
||||
case .breakEven: return "equal.circle.fill"
|
||||
case .notWorthTheDrive: return "xmark.circle.fill"
|
||||
case .baseline: return "location.circle.fill"
|
||||
// The ONE closest glyph, shared with the Key legend and the station
|
||||
// badges (`StationLabelKind.closest`). The other three verdicts are
|
||||
// circular because they report an outcome; closest reports a position,
|
||||
// and the plain location pointer is what the legend teaches for it.
|
||||
case .baseline: return StationLabelKind.closest.symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
/// One caption line's height, scaled with Dynamic Type, for the shared shape
|
||||
/// rule. The drawn height is divided by this, so the shape follows the lines
|
||||
/// actually on screen rather than the number the verdict declares.
|
||||
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
|
||||
|
||||
private var verdict: FuelSavingsResult.ListingVerdict {
|
||||
result.listingVerdict(showsAmount: showsAmount)
|
||||
}
|
||||
|
||||
private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) }
|
||||
|
||||
var body: some View {
|
||||
AdaptiveCapsule(
|
||||
fill: tone.background,
|
||||
lineHeight: captionLineHeight,
|
||||
declaredLineCount: verdict.lines.count,
|
||||
horizontalPadding: 8,
|
||||
verticalPadding: 4
|
||||
) {
|
||||
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)
|
||||
}
|
||||
// Tinted foreground on a tinted fill. These capsules carry a figure the
|
||||
// eye is asked to read at a glance, so they are filled rather than
|
||||
// tinted-text-only; every badge above them stays the quieter tinted form.
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tone.foreground)
|
||||
}
|
||||
// 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)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(verdict.spokenText)
|
||||
}
|
||||
|
||||
/// Saves and Costs only — the two verdicts that carry a figure. 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 symbol: String {
|
||||
switch result.status {
|
||||
case .worthTheDrive: return "checkmark.circle"
|
||||
case .notWorthTheDrive: return "arrow.up.circle"
|
||||
case .breakEven: return "equal.circle"
|
||||
// Same closest glyph as the detail verdict and the Key — this capsule is
|
||||
// the listing's copy of the same state, not a third icon for it.
|
||||
case .baseline: return StationLabelKind.closest.symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "How Best Value works" explanation as its own sheet.
|
||||
/// The "How does FuelBoard calculate value?" 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
|
||||
@@ -14,8 +14,54 @@ struct HowBestValueSheet: View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Best Value compares each station with the closest eligible 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("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)
|
||||
}
|
||||
@@ -23,7 +69,11 @@ struct HowBestValueSheet: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Best Value works")
|
||||
.navigationTitle(KeySheetContent.helpSheetTitle)
|
||||
// `.inline`, the mode this sheet has always used. The sheet's title is a
|
||||
// short noun phrase and the ROW that opens it carries the full question
|
||||
// — a nav title is clamped to the bar and even `.large` truncated the
|
||||
// full question to "How does FuelBoard…" rather than wrapping it.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
@@ -31,8 +81,46 @@ struct HowBestValueSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.fraction(0.42)])
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +134,7 @@ struct HowBestValueRow: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Text(KeySheetContent.helpTitle)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
|
||||
@@ -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 | ||||