Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9e71f7ac | ||
|
|
fcb11a8f52 | ||
|
|
ab93f6286a | ||
|
|
6edab35400 | ||
|
|
244f9be640 | ||
|
|
c46a7748e6 | ||
|
|
6cbb083208 | ||
|
|
c331f3d663 | ||
|
|
7aa2485994 | ||
|
|
c6e252d194 | ||
|
|
7aaaffa1c5 | ||
|
|
7111ce38af | ||
|
|
ff6cbbe07d | ||
|
|
f261b0b10a | ||
|
|
1b6184c035 |
+18
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
scene (CPListTemplate) for navigation deep-links.
|
scene (CPListTemplate) for navigation deep-links.
|
||||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||||||
|
- [ ] **Restore Best Value proximity alerts after fixing evaluation parity** —
|
||||||
|
the option is intentionally removed from the Alerts tab and legacy selections
|
||||||
|
fall back to `Cheapest station` until this is implemented. Root cause: the live
|
||||||
|
geofence path calculates journeys from the entered station, making that station
|
||||||
|
its own zero-saving baseline and therefore unable to clear any positive minimum
|
||||||
|
saving, while Test alert calculates from the user's location and can pass.
|
||||||
|
Future implementation must obtain a fresh user fix on region entry; fetch,
|
||||||
|
radius-filter, choose the closest eligible baseline and calculate every journey
|
||||||
|
from that user location; then notify only when the entered station is the best
|
||||||
|
qualifying candidate. Real and Test alerts must call one shared pure evaluator.
|
||||||
|
Apply one disclosed qualification rule everywhere (real alert, Test alert and
|
||||||
|
BEST VALUE badge): remove the live path's hidden extra £0.50 estimate margin,
|
||||||
|
compare the same penny-rounded amount shown to the user while retaining raw
|
||||||
|
precision for ranking, and pass the current threshold explicitly into station
|
||||||
|
rows instead of reading persistence per row. Regression coverage: entered
|
||||||
|
station can qualify, a better nearby candidate blocks it, real/Test parity,
|
||||||
|
exact and display-rounding boundaries, estimated/confirmed parity, immediate
|
||||||
|
threshold changes, and below-threshold rejection.
|
||||||
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
||||||
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
||||||
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
||||||
|
|||||||
@@ -533,7 +533,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -826,7 +826,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -851,7 +851,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -900,7 +900,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
|
|||||||
+38
-73
@@ -6,18 +6,12 @@ import SwiftUI
|
|||||||
/// versa). The Live Activity section also lives here — it mirrors this page's
|
/// versa). The Live Activity section also lives here — it mirrors this page's
|
||||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||||
/// Settings → Debug; the user-facing page keeps short descriptions that follow
|
/// Settings → Debug; the user-facing page keeps short descriptions of the
|
||||||
/// the selected alert type and radius, plus a 0-stations hint.
|
/// cheapest-station rule and radius, plus a 0-stations hint.
|
||||||
struct AlertsView: View {
|
struct AlertsView: View {
|
||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double // stored in km (monitor + storage)
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||||
@Binding var alertMode: AlertMode
|
|
||||||
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
|
|
||||||
/// the fill size and MPG that decide the saving live. Read back so the
|
|
||||||
/// alert's own rule is visible without leaving the tab.
|
|
||||||
let bestValueThresholdGBP: Double
|
|
||||||
let bestValueConfigured: Bool
|
|
||||||
@Binding var favouriteDropEnabled: Bool
|
@Binding var favouriteDropEnabled: Bool
|
||||||
@Binding var favouriteDropFuel: FuelType
|
@Binding var favouriteDropFuel: FuelType
|
||||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||||
@@ -61,17 +55,6 @@ struct AlertsView: View {
|
|||||||
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the selected alert type actually does. Swapped with the picker so
|
|
||||||
/// the copy can never describe the wrong mode.
|
|
||||||
private var alertModeDescription: String {
|
|
||||||
switch alertMode {
|
|
||||||
case .cheapest:
|
|
||||||
return String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description")
|
|
||||||
case .bestValue:
|
|
||||||
return String(localized: "Get notified when you're near a station that could save you money after travel costs.", comment: "Best Value proximity alert description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The radius the selected option really monitors, stated in the user's
|
/// 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
|
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
||||||
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
||||||
@@ -166,9 +149,8 @@ struct AlertsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// An option row that belongs to a toggle. When the toggle is off the control
|
/// An option row that belongs to a toggle. When the toggle is off the control
|
||||||
/// is replaced by a dimmed, static value row: `.disabled` is not enough here
|
/// is replaced by a static value row. The enclosing dependent group applies
|
||||||
/// because an inline-style picker in a List still expands its options when
|
/// FuelBoard's shared disabled interaction and appearance once.
|
||||||
/// tapped while disabled.
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func optionRow<Content: View>(_ title: String,
|
private func optionRow<Content: View>(_ title: String,
|
||||||
value: String,
|
value: String,
|
||||||
@@ -182,7 +164,6 @@ struct AlertsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,62 +172,41 @@ struct AlertsView: View {
|
|||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
Group {
|
||||||
value: favouriteDropFuel.displayName,
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
isEnabled: favouriteDropEnabled) {
|
value: favouriteDropFuel.displayName,
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
isEnabled: favouriteDropEnabled) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||||
Text(fuel.displayName).tag(fuel)
|
ForEach(FuelType.allCases) { fuel in
|
||||||
|
Text(fuel.displayName).tag(fuel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
|
||||||
.disabled(!favouriteDropEnabled)
|
|
||||||
.allowsHitTesting(favouriteDropEnabled)
|
|
||||||
Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description"))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
|
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||||
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
|
Group {
|
||||||
value: alertMode.displayName,
|
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
|
||||||
isEnabled: enabled) {
|
|
||||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
|
||||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
|
||||||
Text(mode.displayName).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !bestValueConfigured {
|
|
||||||
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
|
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
Text(alertModeDescription)
|
value: fuel.displayName,
|
||||||
.font(.footnote)
|
isEnabled: enabled) {
|
||||||
.foregroundStyle(.secondary)
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||||
if alertMode == .bestValue {
|
ForEach(FuelType.allCases) { fuel in
|
||||||
// The threshold is edited in Settings → Fuel Savings: it
|
Text(fuel.displayName).tag(fuel)
|
||||||
// labels station rows BEST VALUE as well as gating this
|
}
|
||||||
// alert, so it lives with the fill size and MPG that
|
|
||||||
// produce the saving. Read back here, not offered twice.
|
|
||||||
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
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"),
|
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
|
||||||
value: alertRadiusValueLabel,
|
value: alertRadiusValueLabel,
|
||||||
isEnabled: enabled) {
|
isEnabled: enabled) {
|
||||||
@@ -265,9 +225,11 @@ struct AlertsView: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.fuelBoardDisabledState(isEnabled: enabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||||
}
|
}
|
||||||
@@ -281,7 +243,8 @@ struct AlertsView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
Group {
|
||||||
|
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||||
value: liveActivityFuel.displayName,
|
value: liveActivityFuel.displayName,
|
||||||
isEnabled: liveActivityEnabled) {
|
isEnabled: liveActivityEnabled) {
|
||||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||||
@@ -305,9 +268,11 @@ struct AlertsView: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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"))
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 closestStation = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
|
||||||
|
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Blue, like `cheapest`: the top favourite IS the cheapest favourite, so it
|
||||||
|
/// must not read as a different class of thing.
|
||||||
|
static let top = CapsuleTone(
|
||||||
|
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
|
||||||
|
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 — "Closest station" is the case that
|
||||||
|
/// forced this: one declared line that wraps to two in the listing's price
|
||||||
|
/// column.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-100
@@ -37,7 +37,6 @@ struct ContentView: View {
|
|||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
|
||||||
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
|
|
||||||
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||||
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
|
||||||
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
|
||||||
@@ -152,23 +151,13 @@ struct ContentView: View {
|
|||||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveAlertsMode: AlertMode {
|
|
||||||
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureBestValueAlertModeIsAvailable() {
|
|
||||||
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
|
||||||
monitor.update(
|
monitor.update(
|
||||||
stations: stations,
|
stations: stations,
|
||||||
favourites: refreshedFavourites,
|
favourites: refreshedFavourites,
|
||||||
fuel: overrideFuel ?? alertsFuel,
|
fuel: overrideFuel ?? alertsFuel,
|
||||||
radiusKM: effectiveAlertsRadiusKM,
|
radiusKM: effectiveAlertsRadiusKM,
|
||||||
alertMode: effectiveAlertsMode,
|
alertMode: .cheapest,
|
||||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
||||||
fuelSavingsSettings: fuelSavingsSettings,
|
fuelSavingsSettings: fuelSavingsSettings,
|
||||||
fuelSavingsAmount: sessionFuelSavingsAmount
|
fuelSavingsAmount: sessionFuelSavingsAmount
|
||||||
@@ -1154,7 +1143,6 @@ struct ContentView: View {
|
|||||||
// or the choice wouldn't take effect until the next launch.
|
// or the choice wouldn't take effect until the next launch.
|
||||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||||
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
installLocationUpdateHook()
|
installLocationUpdateHook()
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
@@ -1174,7 +1162,6 @@ struct ContentView: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
|
||||||
#endif
|
#endif
|
||||||
ensureBestValueAlertModeIsAvailable()
|
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
}
|
}
|
||||||
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
.onChange(of: sessionFuelSavingsAmount) { _, _ in
|
||||||
@@ -1257,16 +1244,6 @@ struct ContentView: View {
|
|||||||
FuelStore.saveAlertsFuel(newValue)
|
FuelStore.saveAlertsFuel(newValue)
|
||||||
updateAlertMonitor(fuel: newValue)
|
updateAlertMonitor(fuel: newValue)
|
||||||
}
|
}
|
||||||
.onChange(of: alertsMode) { _, newValue in
|
|
||||||
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
|
|
||||||
alertsMode = .cheapest
|
|
||||||
FuelStore.saveAlertsMode(.cheapest)
|
|
||||||
updateAlertMonitor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
FuelStore.saveAlertsMode(newValue)
|
|
||||||
updateAlertMonitor()
|
|
||||||
}
|
|
||||||
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
|
||||||
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
|
||||||
updateAlertMonitor()
|
updateAlertMonitor()
|
||||||
@@ -1609,9 +1586,6 @@ struct ContentView: View {
|
|||||||
enabled: $alertsEnabled,
|
enabled: $alertsEnabled,
|
||||||
radius: $alertsRadius,
|
radius: $alertsRadius,
|
||||||
fuel: $alertsFuel,
|
fuel: $alertsFuel,
|
||||||
alertMode: $alertsMode,
|
|
||||||
bestValueThresholdGBP: alertsBestValueThresholdGBP,
|
|
||||||
bestValueConfigured: fuelSavingsSettings.isConfigured,
|
|
||||||
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
favouriteDropEnabled: $favouriteDropAlertsEnabled,
|
||||||
favouriteDropFuel: $favouriteDropAlertsFuel,
|
favouriteDropFuel: $favouriteDropAlertsFuel,
|
||||||
followsSearch: $alertsFollowsSearch,
|
followsSearch: $alertsFollowsSearch,
|
||||||
@@ -1846,8 +1820,8 @@ struct StationRow: View {
|
|||||||
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
|
/// 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
|
/// 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 hairline, the price axis and the star cannot move as the list scrolls.
|
||||||
/// The price column takes the remainder, and its savings capsule scales down
|
/// The price column takes the remainder, and its listing verdict wraps
|
||||||
/// instead of widening the column and dragging the hairline with it.
|
/// 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
|
/// 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`
|
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
|
||||||
@@ -1857,11 +1831,6 @@ struct StationRow: View {
|
|||||||
/// in pounds & pence, and names keep the same room in both styles.
|
/// in pounds & pence, and names keep the same room in both styles.
|
||||||
private static let leftSectionShare: CGFloat = 0.52
|
private static let leftSectionShare: CGFloat = 0.52
|
||||||
|
|
||||||
/// The comparison label sits beside the capsule only where the row is wide
|
|
||||||
/// enough for both (regular width). On an iPhone the mockup's canvas is
|
|
||||||
/// wider than the whole screen, so the label drops under the capsule rather
|
|
||||||
/// than starving the station name.
|
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
||||||
|
|
||||||
private var ragColor: Color {
|
private var ragColor: Color {
|
||||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||||
@@ -1972,14 +1941,18 @@ struct StationRow: View {
|
|||||||
|
|
||||||
// Name, full address, distance
|
// Name, full address, distance
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
// The name WRAPS instead of truncating: the row is tall
|
// The name wraps, capped at THREE lines. The row is tall
|
||||||
// enough now, and a name cut to "Z A Service Stat…" cannot
|
// enough for a two-line name, but an unbounded wrap let one
|
||||||
// be told apart from the station next door. Baseline
|
// very long forecourt name set the height of the whole row —
|
||||||
// alignment keeps the notice symbols on the name's FIRST
|
// the excessive row heights this refinement removes. Past
|
||||||
// line instead of floating beside a two-line name.
|
// 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) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(station.name)
|
Text(station.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.lineLimit(3)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
ForEach(StationNotice.notices(for: station)) { notice in
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
@@ -2050,29 +2023,33 @@ struct StationRow: View {
|
|||||||
if let badge = recommendationBadge {
|
if let badge = recommendationBadge {
|
||||||
badgeCapsule(badge)
|
badgeCapsule(badge)
|
||||||
}
|
}
|
||||||
// Price line: the status dot and the price, leading-aligned on
|
// The price and its verdict capsule are ONE vertically grouped
|
||||||
// the column's one axis. The star no longer ends this line — it
|
// element: 5pt binds them as "this price, this outcome", while
|
||||||
// sits under the station logo — so this whole column width now
|
// the badge keeps the column's full 12pt above so the three
|
||||||
// belongs to the price, which is what lets the wide pounds &
|
// lines do not read as equals.
|
||||||
// pence form (£1.58⁷/L) render at full size rather than being
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
// scaled down or truncated.
|
// Price line: the status dot and the price, leading-aligned
|
||||||
if let price = station.prices[fuel] {
|
// on the column's one axis. The star no longer ends this
|
||||||
HStack(spacing: 5) {
|
// line — it sits under the station logo — so this whole
|
||||||
Circle()
|
// column width now belongs to the price, which is what lets
|
||||||
.fill(ragColor)
|
// the wide pounds & pence form (£1.58⁷/L) render at full
|
||||||
.frame(width: 8, height: 8)
|
// size rather than being scaled down or truncated.
|
||||||
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
|
if let price = station.prices[fuel] {
|
||||||
size: priceSize, weight: .bold)
|
HStack(spacing: 5) {
|
||||||
.lineLimit(1)
|
PriceRatingDot(color: ragColor)
|
||||||
.minimumScaleFactor(0.8)
|
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))
|
||||||
|
}
|
||||||
|
if let result = fuelSavings?.result {
|
||||||
|
savingsCapsule(result)
|
||||||
}
|
}
|
||||||
// The visible "+1.8p vs cheapest" line is gone with the
|
|
||||||
// redesign, so the comparison rides on the price's label.
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
.accessibilityLabel(priceSpokenLabel(price))
|
|
||||||
}
|
|
||||||
if let result = fuelSavings?.result {
|
|
||||||
savingsCapsule(result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -2093,62 +2070,40 @@ struct StationRow: View {
|
|||||||
Button {
|
Button {
|
||||||
onToggleFavourite()
|
onToggleFavourite()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isFavourite ? "star.fill" : "star")
|
FavouriteStarGlyph(isFavourite: isFavourite)
|
||||||
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The savings capsule plus the comparison it is measured against. The
|
/// The listing's verdict capsule — ONE component for every status, so the
|
||||||
/// capsule itself is the shared verdict view, so a row, the detail sheet and
|
/// baseline's "Closest station" pill carries the same soft blue fill, white
|
||||||
/// the compact summary cannot drift apart. The comparison label sits UNDER
|
/// text and shape rules as Saves, Costs and Break even. The detail sheet
|
||||||
/// the capsule, trailing-aligned with it, at every width — beside it, a long
|
/// keeps `FuelSavingsCapsule` to itself, unchanged.
|
||||||
/// verdict pushed the caption off the row and the pair no longer read as one
|
|
||||||
/// statement.
|
|
||||||
@ViewBuilder
|
|
||||||
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
ListingFuelSavingsCapsule(result: result)
|
||||||
FuelSavingsCapsule(result: result)
|
|
||||||
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
// Same narrow column as the capsule above it, so the same floor:
|
|
||||||
// scaled copy, not a trimmed "vs closest st…".
|
|
||||||
.minimumScaleFactor(0.7)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One recommendation badge, above the price it annotates. Tinted rather
|
/// One recommendation badge, above the price it annotates. Badges stay
|
||||||
/// than the mockup's solid fill, like every other badge and capsule in the
|
/// TINTED with coloured text — unlike the verdict capsule beneath, which is
|
||||||
/// app: coloured text keeps its contrast in both appearances, at every
|
/// filled: a badge annotates the price rather than reporting an outcome, so it
|
||||||
/// Dynamic Type size, without introducing a colour outside the palette.
|
/// 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 {
|
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
|
||||||
Text(badge.title)
|
StationLabelBadge(kind: badge.labelKind)
|
||||||
.font(.caption2.bold())
|
|
||||||
.foregroundStyle(badge.tint)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(badge.tint.opacity(0.15)))
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
|
||||||
/// they stay out of the shared model the tests cover.
|
/// they stay out of the shared model the tests cover.
|
||||||
private extension StationRecommendationBadge {
|
private extension StationRecommendationBadge {
|
||||||
var title: String {
|
/// 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 {
|
switch self {
|
||||||
case .bestValue:
|
case .bestValue: return .bestValue
|
||||||
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
|
case .cheapest: return .cheapest
|
||||||
case .cheapest:
|
|
||||||
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tint: Color { self == .bestValue ? .green : .blue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Location manager
|
// MARK: - Location manager
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// The Key (legend) sheet — the same sheet the Stations tab presents, opened
|
||||||
|
/// from the info button beside Edit.
|
||||||
|
@State private var showKey = false
|
||||||
|
/// The Key sheet's two nested sheets, owned here because its rows set them
|
||||||
|
/// (same arrangement as StationsView, so the QA hooks behave identically).
|
||||||
|
@State private var showBestValueInfo = false
|
||||||
|
@State private var showImportantInformation = false
|
||||||
|
|
||||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
@State private var detailStation: FuelStation?
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
@@ -69,6 +77,15 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
|
||||||
|
|
||||||
|
/// The baseline note is useful only when at least one visible favourite has
|
||||||
|
/// an actual savings comparison. An unavailable result (for example, while
|
||||||
|
/// location is missing) must not leave an orphaned explanation below the list.
|
||||||
|
private var showsSavingsComparisonExplanation: Bool {
|
||||||
|
fuelSavingsSettings.isConfigured && ordered.contains { station in
|
||||||
|
savingsForStation(station)?.result != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// IDs favourited for the active fuel — the star state on each row.
|
/// IDs favourited for the active fuel — the star state on each row.
|
||||||
private var activeFuelFavouriteIDs: Set<String> {
|
private var activeFuelFavouriteIDs: Set<String> {
|
||||||
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
||||||
@@ -145,8 +162,15 @@ struct FavouritesView: View {
|
|||||||
if fuelSavingsSettings.isConfigured {
|
if fuelSavingsSettings.isConfigured {
|
||||||
Section {
|
Section {
|
||||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
} footer: {
|
}
|
||||||
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +195,12 @@ struct FavouritesView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
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 {
|
if let cheapest = cheapest {
|
||||||
@@ -185,8 +214,8 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !favourites.isEmpty {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
if !favourites.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
showTrends = true
|
showTrends = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -195,8 +224,24 @@ struct FavouritesView: View {
|
|||||||
}
|
}
|
||||||
EditButton()
|
EditButton()
|
||||||
}
|
}
|
||||||
|
// Same button and same slot as the Stations tab: last in the
|
||||||
|
// group, so it sits to the right of Edit. Shown even with no
|
||||||
|
// favourites — the legend explains the prices and labels, which
|
||||||
|
// matter before anything is starred.
|
||||||
|
Button {
|
||||||
|
showKey = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
}
|
||||||
|
.accessibilityLabel(String(localized: "Key", comment: "Key button accessibility label"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showKey) {
|
||||||
|
KeySheetView(
|
||||||
|
showBestValueInfo: $showBestValueInfo,
|
||||||
|
showImportantInformation: $showImportantInformation
|
||||||
|
)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showTrends) {
|
.sheet(isPresented: $showTrends) {
|
||||||
TrendsView(
|
TrendsView(
|
||||||
favourites: favourites,
|
favourites: favourites,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import SwiftUI
|
|||||||
struct FuelSavingsAdjustmentView: View {
|
struct FuelSavingsAdjustmentView: View {
|
||||||
@Binding var amount: FuelSavingsFillAmount
|
@Binding var amount: FuelSavingsFillAmount
|
||||||
|
|
||||||
@State private var unitChangeNotice: String?
|
@State private var showingComparisonHelp = false
|
||||||
|
|
||||||
private var range: ClosedRange<Double> {
|
private var range: ClosedRange<Double> {
|
||||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||||
@@ -26,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("How much are you filling up?")
|
HStack {
|
||||||
.font(.subheadline.weight(.semibold))
|
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) {
|
Picker("Fill-up unit", selection: unitBinding) {
|
||||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
Text(unit.displayName).tag(unit)
|
Text(unit.displayName).tag(unit)
|
||||||
@@ -42,17 +67,9 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.frame(width: 72, alignment: .trailing)
|
.frame(width: 72, alignment: .trailing)
|
||||||
}
|
}
|
||||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
Text("Adjust your fill-up amount for this visit. Your default won’t change.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
if let unitChangeNotice {
|
|
||||||
Text(unitChangeNotice)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +78,9 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
get: { amount.unit },
|
get: { amount.unit },
|
||||||
set: { newUnit in
|
set: { newUnit in
|
||||||
guard newUnit != amount.unit else { return }
|
guard newUnit != amount.unit else { return }
|
||||||
// No one litre figure equals a budget at every candidate price.
|
// No one litre figure equals a budget at every candidate price,
|
||||||
// A unit change starts a new, explicitly labelled comparison.
|
// so switching modes starts from that mode's configured default.
|
||||||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||||
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,8 +93,8 @@ struct FuelSavingsAdjustmentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The colour-coded verdict capsule shared by the station listing rows, the
|
/// The colour-coded monetary verdict capsule used by the detail sheet and other
|
||||||
/// detail sheet and the compact summary.
|
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
|
||||||
///
|
///
|
||||||
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
/// 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
|
/// a Label lays its icon and title out across the row and leaves a visible gap
|
||||||
@@ -89,38 +105,51 @@ struct FuelSavingsCapsule: View {
|
|||||||
let result: FuelSavingsResult
|
let result: FuelSavingsResult
|
||||||
var compact: Bool = true
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: compact ? 3 : 5) {
|
AdaptiveCapsule(
|
||||||
Image(systemName: Self.symbol(for: result.status))
|
fill: tone.background,
|
||||||
.accessibilityHidden(true)
|
lineHeight: compact ? captionLineHeight : headlineLineHeight,
|
||||||
Text(result.capsuleHeadline())
|
horizontalPadding: compact ? 8 : 12,
|
||||||
.lineLimit(1)
|
verticalPadding: compact ? 4 : 7
|
||||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
) {
|
||||||
// which the icon, its spacing and the 8pt padding either side
|
HStack(spacing: compact ? 3 : 5) {
|
||||||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
Image(systemName: Self.symbol(for: result.status))
|
||||||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
.accessibilityHidden(true)
|
||||||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||||
// which clears it at ~10pt type: scaled, never truncated.
|
// The detail sheet's capsule sits beside a headline and has the
|
||||||
.minimumScaleFactor(0.7)
|
// 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(tone.foreground)
|
||||||
}
|
}
|
||||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
|
||||||
.foregroundStyle(Self.color(for: result.status))
|
|
||||||
.padding(.horizontal, compact ? 8 : 12)
|
|
||||||
.padding(.vertical, compact ? 4 : 7)
|
|
||||||
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
/// Verdict tone: green save, red worse off, grey break even, neutral-grey
|
||||||
/// baseline — the same green/amber/red family the price dot already uses.
|
/// baseline — the same meanings the price dot's RAG family carries.
|
||||||
static func color(for status: FuelSavingsStatus) -> Color {
|
static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return .green
|
case .worthTheDrive: return .saves
|
||||||
case .notWorthTheDrive: return .red
|
case .notWorthTheDrive: return .costs
|
||||||
case .breakEven: return .secondary
|
case .breakEven: return .breakEven
|
||||||
case .baseline: return .blue
|
case .baseline: return .closestStation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .worthTheDrive: return "checkmark.circle.fill"
|
case .worthTheDrive: return "checkmark.circle.fill"
|
||||||
@@ -130,3 +159,90 @@ struct FuelSavingsCapsule: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The station listing's verdict capsule: the words on the first line, the figure
|
||||||
|
/// on its own line beneath, and "(approx.)" on a third when the journey is an
|
||||||
|
/// estimate.
|
||||||
|
///
|
||||||
|
/// Stations and Favourites share `StationRow`, so both render this. The detail
|
||||||
|
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
|
||||||
|
/// the Key sheet's legend renders this with `showsAmount: false` so it never
|
||||||
|
/// displays an invented figure.
|
||||||
|
struct ListingFuelSavingsCapsule: View {
|
||||||
|
let result: FuelSavingsResult
|
||||||
|
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
|
||||||
|
var showsAmount: Bool = true
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
case .baseline: return "location.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ struct HowBestValueRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("How Best Value works")
|
Text(KeySheetContent.helpTitle)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
Image(systemName: "chevron.forward")
|
Image(systemName: "chevron.forward")
|
||||||
.font(.footnote.weight(.semibold))
|
.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 between entry points.
|
||||||
|
///
|
||||||
|
/// One `Section` per topic, so every heading gets its own card: an accent
|
||||||
|
/// symbol beside a semibold title, with the secondary-coloured description
|
||||||
|
/// beneath. First host: the Stations tab's Key sheet (a row at the bottom). Copy
|
||||||
|
/// lives in `Localizable.strings`, keyed by the English source string, so the web
|
||||||
|
/// copy editor covers it.
|
||||||
|
struct ImportantInformationSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Width of the symbol column that every heading's icon sits in.
|
||||||
|
///
|
||||||
|
/// Symbols have wildly different ink widths — `car.side` is ~34 pt wide at
|
||||||
|
/// this size while `info.circle` is ~20 pt — so without a shared column each
|
||||||
|
/// heading's TEXT would start at a different x (measured: 180 px vs 233 px
|
||||||
|
/// at 3x), which is exactly the inconsistent alignment the brief rules out.
|
||||||
|
/// `@ScaledMetric` keeps the column in step with the icon font, so it still
|
||||||
|
/// can't overrun at large Dynamic Type sizes.
|
||||||
|
@ScaledMetric(relativeTo: .title3) private var iconSlot: CGFloat = 34
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.",
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Prices", systemImage: "sterlingsign.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.",
|
||||||
|
"We recommend checking availability before making a special journey."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Fuel Availability", systemImage: "fuelpump.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.",
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Savings & Best Value", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.",
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Journey Estimates", systemImage: "car.side")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Before You Travel", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
paragraphs(
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.",
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided."
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
heading("Disclaimer", systemImage: "info.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The brief asks for ~24 pt between topics; the list's own default is
|
||||||
|
// wider and unsettable, this pins it.
|
||||||
|
.listSectionSpacing(.custom(24))
|
||||||
|
.navigationTitle("Important information")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section heading: accent symbol + semibold title, laid out horizontally
|
||||||
|
/// and vertically centred, with the symbol at a consistent size in every
|
||||||
|
/// section.
|
||||||
|
///
|
||||||
|
/// `.title3` is 20 pt at the default Dynamic Type size — inside the 18–20 pt
|
||||||
|
/// brief — and still scales with the user's text size, so the symbols stay in
|
||||||
|
/// proportion to the headings rather than drifting apart on large sizes.
|
||||||
|
///
|
||||||
|
/// The symbol is decorative (the heading text already carries the meaning),
|
||||||
|
/// so it is hidden from VoiceOver to stop it being announced as a stray glyph.
|
||||||
|
private func heading(_ title: String, systemImage: String) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
// Same-width column for every symbol, so all six headings'
|
||||||
|
// text starts at one x regardless of how wide the glyph is.
|
||||||
|
.frame(width: iconSlot)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(title))
|
||||||
|
.font(.headline)
|
||||||
|
// `Color.primary`, not `.primary`: the bare ShapeStyle resolves
|
||||||
|
// against the List header's own secondary style and comes out
|
||||||
|
// grey — measured (152,152,159), i.e. identical to the body text,
|
||||||
|
// which throws away the heading/description hierarchy.
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
// Let a long heading wrap instead of truncating on a narrow
|
||||||
|
// device or at a large text size.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card's paragraphs, stacked as prose rather than as separate `List` rows
|
||||||
|
/// so the card reads as one statement and the list draws no hairline between
|
||||||
|
/// a topic's own sentences.
|
||||||
|
///
|
||||||
|
/// `LocalizedStringKey` is what makes a `String` parameter localisable —
|
||||||
|
/// `Text(someString)` would render verbatim and the copy editor could never
|
||||||
|
/// reach it.
|
||||||
|
@ViewBuilder
|
||||||
|
private func paragraphs(_ lines: String...) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text(LocalizedStringKey(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row that opens `ImportantInformationSheet`. Reusable: drop it in any
|
||||||
|
/// `List` section.
|
||||||
|
///
|
||||||
|
/// `.buttonStyle(.plain)` keeps it from becoming a row-wide tap target that
|
||||||
|
/// could swallow a neighbouring control (see the two-buttons-in-one-row rule).
|
||||||
|
struct ImportantInformationRow: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Important information")
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Presents the shared "Important information" disclaimer. One call site per
|
||||||
|
/// surface — the sheet itself is never duplicated.
|
||||||
|
func importantInformationSheet(isPresented: Binding<Bool>) -> some View {
|
||||||
|
sheet(isPresented: isPresented) { ImportantInformationSheet() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// QA hook support: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
/// disclaimer on top of it, so the copy can be screenshotted without a tap
|
||||||
|
/// (simctl has no tap command) — the same chained pattern as
|
||||||
|
/// `-showBestValueInfo`. The disclaimer's `.sheet` lives inside the Key sheet's
|
||||||
|
/// content, so it cannot present while the Key sheet is shut.
|
||||||
|
enum ImportantInformationQA {
|
||||||
|
static var isRequested: Bool {
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-showImportantInfo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -82,6 +82,9 @@ struct SettingsView: View {
|
|||||||
@State private var fillUpDraft: Double?
|
@State private var fillUpDraft: Double?
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
|
/// The shared "Important information" disclaimer sheet, opened from the row
|
||||||
|
/// at the bottom of the tab.
|
||||||
|
@State private var showImportantInformation = false
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
/// type (keychain) + the installed-widget inventory from WidgetCenter.
|
||||||
@State private var smallWidgetDiag: String?
|
@State private var smallWidgetDiag: String?
|
||||||
@@ -198,7 +201,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!fuelSavingsSettings.enabled)
|
.fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Fuel Savings")
|
Text("Fuel Savings")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -582,7 +585,7 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(tipStore.purchaseInProgress)
|
.fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -607,11 +610,16 @@ struct SettingsView: View {
|
|||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ImportantInformationRow { showImportantInformation = true }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||||
.keyboardDoneButton { focusedSavingsField = nil }
|
.keyboardDoneButton { focusedSavingsField = nil }
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await tipStore.load() }
|
Task { await tipStore.load() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ struct BestValueDetailCard: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
FuelSavingsCapsule(result: result, compact: false)
|
FuelSavingsCapsule(result: result, compact: false)
|
||||||
.accessibilityLabel(result.spokenHeadline())
|
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
|
||||||
comparison(result)
|
comparison(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+295
-115
@@ -1,6 +1,101 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
|
/// The "Key" legend: price ratings, savings capsules, station labels, the
|
||||||
|
/// favourite glyph, then the two deeper explanations. One sheet for both list
|
||||||
|
/// tabs — Stations opens it from the info button in the navigation bar,
|
||||||
|
/// Favourites from the button beside Edit — so the legend cannot drift between
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// Dismisses through the environment rather than the presenting view's state, and
|
||||||
|
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
|
||||||
|
/// QA launch-argument hooks.
|
||||||
|
struct KeySheetView: View {
|
||||||
|
@Binding var showBestValueInfo: Bool
|
||||||
|
@Binding var showImportantInformation: Bool
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
|
||||||
|
}
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
|
||||||
|
}
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
|
||||||
|
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Price ratings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(KeySheetContent.savings, id: \.label) { item in
|
||||||
|
KeySheetDescriptionRow(item: item) {
|
||||||
|
KeySavingsCapsule(label: item.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Savings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(StationLabelKind.allCases, id: \.self) { kind in
|
||||||
|
KeySheetDescriptionRow(item: item(for: kind)) {
|
||||||
|
KeyStationLabelIndicator(kind: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Station labels")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
|
||||||
|
FavouriteStarGlyph(isFavourite: true)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Favourites")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HowBestValueRow { showBestValueInfo = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ImportantInformationRow { showImportantInformation = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Key")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nested sheet: the Key sheet stays behind the explanation.
|
||||||
|
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||||
|
// Nested sheet: the Key sheet stays behind the disclaimer.
|
||||||
|
.importantInformationSheet(isPresented: $showImportantInformation)
|
||||||
|
}
|
||||||
|
.presentationDetents([.fraction(0.6), .large])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key row's copy for a label kind. Fails loudly in debug rather than
|
||||||
|
/// silently rendering a blank row if a kind is ever added without its entry.
|
||||||
|
private func item(for kind: StationLabelKind) -> KeySheetContent.Item {
|
||||||
|
if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
assertionFailure("No KeySheetContent.stationLabels entry for \(kind)")
|
||||||
|
return KeySheetContent.Item(label: kind.label, description: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
|
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
|
||||||
struct StationsView: View {
|
struct StationsView: View {
|
||||||
let stations: [FuelStation]
|
let stations: [FuelStation]
|
||||||
@@ -39,6 +134,11 @@ struct StationsView: View {
|
|||||||
/// the Key sheet and from the More info sheet.
|
/// the Key sheet and from the More info sheet.
|
||||||
@State private var showBestValueInfo = false
|
@State private var showBestValueInfo = false
|
||||||
|
|
||||||
|
/// The shared "Important information" disclaimer sheet. It lives at the
|
||||||
|
/// bottom of the Key sheet here; the same sheet is reused wherever else the
|
||||||
|
/// disclaimer is needed.
|
||||||
|
@State private var showImportantInformation = false
|
||||||
|
|
||||||
/// Station picked for the More-info sheet (tap action = showDetails).
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
@State private var detailStation: FuelStation?
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
@@ -123,6 +223,13 @@ struct StationsView: View {
|
|||||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fuelSavingsSettings.isConfigured && !stations.isEmpty {
|
||||||
|
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
if isLoading && stations.isEmpty {
|
if isLoading && stations.isEmpty {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -216,6 +323,17 @@ struct StationsView: View {
|
|||||||
showBestValueInfo = true
|
showBestValueInfo = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// QA hook: `-showImportantInfo` opens the Key sheet and then the
|
||||||
|
// shared disclaimer on top of it, so the copy can be
|
||||||
|
// screenshotted without a tap. Same chained requirement as
|
||||||
|
// `-showBestValueInfo` above.
|
||||||
|
if ImportantInformationQA.isRequested {
|
||||||
|
showKey = true
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
showImportantInformation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
|
||||||
@@ -273,87 +391,14 @@ struct StationsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
|
||||||
|
/// every station label has a fixed semantic tone, so the legend reads the same
|
||||||
|
/// whichever grade the reader is looking at.
|
||||||
private var keySheet: some View {
|
private var keySheet: some View {
|
||||||
NavigationStack {
|
KeySheetView(
|
||||||
List {
|
showBestValueInfo: $showBestValueInfo,
|
||||||
Section {
|
showImportantInformation: $showImportantInformation
|
||||||
VStack(spacing: 10) {
|
)
|
||||||
keyCard(
|
|
||||||
icon: "checkmark.circle.fill", color: .green,
|
|
||||||
title: "Good price", blurb: "Within 1.5p of the cheapest",
|
|
||||||
threshold: "0–1.5p")
|
|
||||||
keyCard(
|
|
||||||
icon: "equal.circle.fill", color: .orange,
|
|
||||||
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
|
||||||
threshold: ">1.5–4p")
|
|
||||||
keyCard(
|
|
||||||
icon: "exclamationmark.circle.fill", color: .red,
|
|
||||||
title: "Pricey", blurb: "More than 4p above the cheapest",
|
|
||||||
threshold: ">4p")
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color(.separator))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text("TOP")
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(.blue.opacity(0.16)))
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
Text("Top result for the current sort")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .blue)
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
.frame(width: 34)
|
|
||||||
Text("Star a station to add it to Favourites")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.keyCardFill(color: .yellow)
|
|
||||||
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The explanation is its own sheet now, opened from this
|
|
||||||
// row. Gated exactly like the More info row and the Best
|
|
||||||
// Value card itself: `isConfigured` is the same condition
|
|
||||||
// `savingsForStation` uses, so the row appears and
|
|
||||||
// disappears with the feature.
|
|
||||||
if fuelSavingsSettings.isConfigured {
|
|
||||||
Section {
|
|
||||||
HowBestValueRow { showBestValueInfo = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Key")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") { showKey = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nested sheet: the Key sheet stays behind the explanation.
|
|
||||||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
|
||||||
}
|
|
||||||
.presentationDetents([.fraction(0.6)])
|
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stationRow(_ station: FuelStation) -> some View {
|
private func stationRow(_ station: FuelStation) -> some View {
|
||||||
@@ -381,45 +426,180 @@ struct StationsView: View {
|
|||||||
return modes
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
}
|
||||||
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
|
||||||
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
/// The station list's real RAG dot, paired with the rating name. The range lives
|
||||||
HStack(spacing: 12) {
|
/// in the aligned description column rather than as a separate trailing value.
|
||||||
Image(systemName: icon)
|
private struct KeyPriceRatingIndicator: View {
|
||||||
.font(.title3)
|
let item: KeySheetContent.Item
|
||||||
.foregroundStyle(color)
|
let color: Color
|
||||||
.frame(width: 34)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
var body: some View {
|
||||||
Text(LocalizedStringKey(title)).font(.headline)
|
HStack(spacing: 5) {
|
||||||
Text(LocalizedStringKey(blurb))
|
PriceRatingDot(color: color)
|
||||||
.font(.caption)
|
Text(LocalizedStringKey(item.label))
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption.weight(.semibold))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle().fill(color).frame(width: 10, height: 10)
|
|
||||||
Text(threshold)
|
|
||||||
.font(.headline.weight(.bold))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.keyCardFill(color: color)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card background shared by the Key sheet cards — subtle accent tint over
|
/// Every Key section shares this one two-column row. The scaled first-column
|
||||||
/// the grouped background, matching the tip-section card style.
|
/// width keeps every description on the same leading axis while preserving the
|
||||||
private extension View {
|
/// inline layout at accessibility sizes; descriptions own the flexible column
|
||||||
func keyCardFill(color: Color) -> some View {
|
/// and grow vertically without truncating.
|
||||||
background(
|
private struct KeySheetDescriptionRow<Indicator: View>: View {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
private let indicatorColumnWidth: CGFloat = 128
|
||||||
.fill(Color(.secondarySystemGroupedBackground))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
let item: KeySheetContent.Item
|
||||||
|
@ViewBuilder let indicator: Indicator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
indicator
|
||||||
|
.frame(width: indicatorColumnWidth, alignment: .leading)
|
||||||
|
description
|
||||||
|
}
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("\(item.label). \(item.description)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var description: some View {
|
||||||
|
Text(LocalizedStringKey(item.description))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the same listing capsule component as real station rows, with neutral
|
||||||
|
/// fixture numbers: only the verdict is visible in this compact variant.
|
||||||
|
private struct KeySavingsCapsule: View {
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
/// Keyed on the shared label so the legend cannot demonstrate a word the
|
||||||
|
/// listing capsule never draws.
|
||||||
|
private var status: FuelSavingsStatus {
|
||||||
|
switch label {
|
||||||
|
case String(localized: "Saves"): return .worthTheDrive
|
||||||
|
case String(localized: "Costs"): return .notWorthTheDrive
|
||||||
|
default: return .breakEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var result: FuelSavingsResult {
|
||||||
|
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
|
||||||
|
return FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: netSaving,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: netSaving,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// The legend demonstrates the wording, not a figure: `result` above is a
|
||||||
|
// placeholder verdict, and its £1.00 must never reach the screen.
|
||||||
|
ListingFuelSavingsCapsule(result: result, showsAmount: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared RAG dot used beside live station prices and in the Key.
|
||||||
|
///
|
||||||
|
/// 12pt is the app's one diameter: it reads at a glance beside a 22pt price
|
||||||
|
/// without competing with it, and it is large enough that the three colours are
|
||||||
|
/// distinguishable for colour-vision-deficient users at arm's length. The widget
|
||||||
|
/// keeps a smaller dot of its own — its rows are far tighter than a phone row —
|
||||||
|
/// so this size is deliberately the APP's.
|
||||||
|
struct PriceRatingDot: View {
|
||||||
|
let color: Color
|
||||||
|
var diameter: CGFloat = PriceRatingDot.standardDiameter
|
||||||
|
|
||||||
|
/// The app's standard dot diameter, in points.
|
||||||
|
static let standardDiameter: CGFloat = 12
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared favourite glyph used by station rows and the Key.
|
||||||
|
struct FavouriteStarGlyph: View {
|
||||||
|
let isFavourite: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: isFavourite ? "star.fill" : "star")
|
||||||
|
.foregroundStyle(isFavourite ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared station-label capsule. `StationRow` uses the same view for its live
|
||||||
|
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
|
||||||
|
///
|
||||||
|
/// One tone per label kind, from the shared palette: purple for best value, blue
|
||||||
|
/// for cheapest and top (a top favourite IS the cheapest favourite, so they share
|
||||||
|
/// a class), neutral for proximity. The symbol is the label's own meaning, sized
|
||||||
|
/// to sit on the caption's cap height rather than to dominate it.
|
||||||
|
struct StationLabelBadge: View {
|
||||||
|
let kind: StationLabelKind
|
||||||
|
|
||||||
|
private var tone: CapsuleTone {
|
||||||
|
switch kind {
|
||||||
|
case .bestValue: return .bestValue
|
||||||
|
case .cheapest: return .cheapest
|
||||||
|
case .closestStation: return .closestStation
|
||||||
|
case .top: return .top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: kind.symbol)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(kind.label))
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
.foregroundStyle(tone.foreground)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(tone.background))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key sheet's indicator for a station label. The closest station is the one
|
||||||
|
/// exception: it renders the listing's real verdict capsule, so the legend
|
||||||
|
/// demonstrates the same fill, text and shape the rows show. Everything else is
|
||||||
|
/// the shared badge.
|
||||||
|
private struct KeyStationLabelIndicator: View {
|
||||||
|
let kind: StationLabelKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if kind == .closestStation {
|
||||||
|
ListingFuelSavingsCapsule(result: baselineResult)
|
||||||
|
} else {
|
||||||
|
StationLabelBadge(kind: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baselineResult: FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
"Alerts" = "Alerts";
|
"Alerts" = "Alerts";
|
||||||
"Settings" = "Settings";
|
"Settings" = "Settings";
|
||||||
"FuelBoard" = "FuelBoard";
|
"FuelBoard" = "FuelBoard";
|
||||||
"TOP" = "TOP";
|
"Top" = "Top";
|
||||||
"BEST VALUE" = "BEST VALUE";
|
"Best value" = "Best value";
|
||||||
"CHEAPEST" = "CHEAPEST";
|
"Cheapest" = "Cheapest";
|
||||||
|
"Closest station" = "Closest station";
|
||||||
|
|
||||||
/* Stations tab */
|
/* Stations tab */
|
||||||
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
|
||||||
@@ -25,14 +26,31 @@
|
|||||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||||
"All %lld stations shown" = "All %lld stations shown";
|
"All %lld stations shown" = "All %lld stations shown";
|
||||||
"Key" = "Key";
|
"Key" = "Key";
|
||||||
|
"Price ratings" = "Price ratings";
|
||||||
"Good price" = "Good price";
|
"Good price" = "Good price";
|
||||||
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
"Within 1.5p/L of the cheapest." = "Within 1.5p/L of the cheapest.";
|
||||||
"Okay" = "Okay";
|
"Okay" = "Okay";
|
||||||
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
|
"More than 1.5p/L, up to 4p/L above the cheapest." = "More than 1.5p/L, up to 4p/L above the cheapest.";
|
||||||
"Pricey" = "Pricey";
|
"Pricey" = "Pricey";
|
||||||
"More than 4p above the cheapest" = "More than 4p above the cheapest";
|
"More than 4p/L above the cheapest." = "More than 4p/L above the cheapest.";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Savings" = "Savings";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Saves" = "Saves";
|
||||||
|
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
|
||||||
|
"Break even" = "Break even";
|
||||||
|
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
|
||||||
|
"Costs" = "Costs";
|
||||||
|
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
|
||||||
|
/* Station listing verdict capsule: the words above the figure */
|
||||||
|
"(approx.)" = "(approx.)";
|
||||||
|
"%@ more" = "%@ more";
|
||||||
|
"Station labels" = "Station labels";
|
||||||
|
"Closest station" = "Closest station";
|
||||||
|
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
|
||||||
|
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
|
||||||
|
"The nearest station by journey distance." = "The nearest station by journey distance.";
|
||||||
|
"The cheapest station in your Favourites for the selected fuel." = "The cheapest station in your Favourites for the selected fuel.";
|
||||||
|
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
|
||||||
|
"How does Best Value work?" = "How does Best Value work?";
|
||||||
/* Star button in the More info sheet heading (accessibility labels) */
|
/* Star button in the More info sheet heading (accessibility labels) */
|
||||||
"Add to favourites" = "Add to favourites";
|
"Add to favourites" = "Add to favourites";
|
||||||
"Remove from favourites" = "Remove from favourites";
|
"Remove from favourites" = "Remove from favourites";
|
||||||
@@ -41,7 +59,7 @@
|
|||||||
"Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
"Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
||||||
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.";
|
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.";
|
||||||
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
||||||
"Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.";
|
"Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs." = "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.";
|
||||||
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
|
||||||
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
||||||
|
|
||||||
@@ -223,7 +241,12 @@
|
|||||||
"Proximity alerts" = "Proximity alerts";
|
"Proximity alerts" = "Proximity alerts";
|
||||||
|
|
||||||
/* Best Value calculation and station price age */
|
/* Best Value calculation and station price age */
|
||||||
"£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity.";
|
"How much fuel?" = "How much fuel?";
|
||||||
|
"Adjust your fill-up amount for this visit. Your default won’t change." = "Adjust your fill-up amount for this visit. Your default won’t change.";
|
||||||
|
"About comparison modes" = "About comparison modes";
|
||||||
|
"Comparison modes" = "Comparison modes";
|
||||||
|
"£: Compare how much fuel your budget buys at each station." = "£: Compare how much fuel your budget buys at each station.";
|
||||||
|
"Litres: Compare the cost of buying the same amount of fuel." = "Litres: Compare the cost of buying the same amount of fuel.";
|
||||||
"You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.";
|
"You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.";
|
||||||
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
||||||
"your order" = "your order";
|
"your order" = "your order";
|
||||||
@@ -244,3 +267,23 @@
|
|||||||
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
||||||
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
||||||
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
||||||
|
|
||||||
|
/* Important information — the shared disclaimer sheet (one card per heading) */
|
||||||
|
"Important information" = "Important information";
|
||||||
|
"Fuel Prices" = "Fuel Prices";
|
||||||
|
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app." = "FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.";
|
||||||
|
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump." = "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump.";
|
||||||
|
"Fuel Availability" = "Fuel Availability";
|
||||||
|
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type." = "A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.";
|
||||||
|
"We recommend checking availability before making a special journey." = "We recommend checking availability before making a special journey.";
|
||||||
|
"Savings & Best Value" = "Savings & Best Value";
|
||||||
|
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings." = "Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.";
|
||||||
|
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost." = "Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost.";
|
||||||
|
"Journey Estimates" = "Journey Estimates";
|
||||||
|
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used." = "Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.";
|
||||||
|
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits." = "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits.";
|
||||||
|
"Before You Travel" = "Before You Travel";
|
||||||
|
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." = "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up.";
|
||||||
|
"Disclaimer" = "Disclaimer";
|
||||||
|
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations." = "FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.";
|
||||||
|
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided." = "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided.";
|
||||||
|
|||||||
@@ -18,6 +18,70 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
func testListingCapsulePutsTheFigureOnItsOwnLine() {
|
||||||
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: status == .notWorthTheDrive ? 0 : 12.50,
|
||||||
|
extraDrivingCostGBP: status == .notWorthTheDrive ? 123.45 : 0,
|
||||||
|
extraDistanceMiles: 2,
|
||||||
|
netSavingGBP: status == .notWorthTheDrive ? -123.45 : 12.50,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The figure takes its own line, so the capsule's shape is decided by the
|
||||||
|
// data rather than by how much width the price column hands it.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Saves", "£12.50"])
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Saves", "£12.50", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more"])
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
|
||||||
|
["Costs", "£123.45 more", "(approx.)"])
|
||||||
|
|
||||||
|
// Break even carries no figure — ±£0.05 is noise — so it stays one line
|
||||||
|
// when confirmed and gains a second only for the estimate qualifier.
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
|
||||||
|
["Break even", "(approx.)"])
|
||||||
|
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
|
||||||
|
|
||||||
|
// The Key sheet's legend demonstrates the wording, never a figure.
|
||||||
|
XCTAssertEqual(
|
||||||
|
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
|
||||||
|
["Costs"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceOver keeps a single line: a drawn break is a spoken pause.
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
|
||||||
|
"Saves £12.50 (approx.)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
|
||||||
|
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: status == .notWorthTheDrive ? 0 : 2.50,
|
||||||
|
extraDrivingCostGBP: status == .notWorthTheDrive ? 1.20 : 0,
|
||||||
|
extraDistanceMiles: 2,
|
||||||
|
netSavingGBP: status == .notWorthTheDrive ? -1.20 : 2.50,
|
||||||
|
status: status,
|
||||||
|
journeyConfidence: confidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleHeadline(), "Save £2.50 (approx.)")
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .estimated).detailCapsuleHeadline(), "Costs £1.20 more (approx.)")
|
||||||
|
XCTAssertEqual(result(.breakEven, .estimated).detailCapsuleHeadline(), "Break even (approx.)")
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .confirmed).detailCapsuleHeadline(), "Save £2.50")
|
||||||
|
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).detailCapsuleHeadline(), "Costs £1.20 more")
|
||||||
|
XCTAssertEqual(result(.breakEven, .confirmed).detailCapsuleHeadline(), "Break even")
|
||||||
|
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleSpokenHeadline(), "Save £2.50 (approximately)")
|
||||||
|
}
|
||||||
|
|
||||||
private func station(_ id: String,
|
private func station(_ id: String,
|
||||||
price: Double?,
|
price: Double?,
|
||||||
lat: Double = 51.5,
|
lat: Double = 51.5,
|
||||||
|
|||||||
@@ -257,3 +257,73 @@ final class StationRecommendationBadgeTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class KeySheetContentTests: XCTestCase {
|
||||||
|
func testPriceRatingsExplainTheImplementedRAGBoundaries() {
|
||||||
|
XCTAssertEqual(KeySheetContent.priceRatings, [
|
||||||
|
.init(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
.init(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
.init(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSavingsExplainVerdictsWithoutImplyingMinimumSavingQualification() {
|
||||||
|
XCTAssertEqual(KeySheetContent.savings, [
|
||||||
|
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
|
.init(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
|
// "Costs" — the listing capsule's own headline, not "Costs more".
|
||||||
|
.init(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
|
])
|
||||||
|
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Key label is sentence case: the capsules are read as phrases, and an
|
||||||
|
/// all-caps set reads as shouting beside the sentence-case copy around it.
|
||||||
|
func testEveryKeyLabelIsSentenceCase() {
|
||||||
|
let labels = (KeySheetContent.priceRatings
|
||||||
|
+ KeySheetContent.savings
|
||||||
|
+ KeySheetContent.stationLabels).map(\.label)
|
||||||
|
for label in labels {
|
||||||
|
XCTAssertNotEqual(label, label.uppercased(),
|
||||||
|
"\(label) must be sentence case, not all caps")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(KeySheetContent.stationLabels.map(\.label),
|
||||||
|
["Best value", "Cheapest", "Closest station", "Top"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Key dispatches its indicators on `StationLabelKind`, so every kind must
|
||||||
|
/// have exactly one entry in the legend — and each entry's label must be the
|
||||||
|
/// kind's own, or the row would describe one label and draw another.
|
||||||
|
func testEveryStationLabelKindHasOneMatchingLegendEntry() {
|
||||||
|
XCTAssertEqual(StationLabelKind.allCases.count, KeySheetContent.stationLabels.count)
|
||||||
|
for kind in StationLabelKind.allCases {
|
||||||
|
let matches = KeySheetContent.stationLabels.filter { $0.label == kind.label }
|
||||||
|
XCTAssertEqual(matches.count, 1, "\(kind) must own exactly one legend entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended symbols, pinned so a rename cannot silently blank a badge.
|
||||||
|
func testStationLabelKindsUseTheirRecommendedSymbols() {
|
||||||
|
XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal")
|
||||||
|
XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag")
|
||||||
|
XCTAssertEqual(StationLabelKind.closestStation.symbol, "location.north.fill")
|
||||||
|
XCTAssertEqual(StationLabelKind.top.symbol, "trophy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
|
||||||
|
XCTAssertEqual(KeySheetContent.stationLabels, [
|
||||||
|
.init(label: "Best value", description: "Meets your minimum saving after travel costs."),
|
||||||
|
.init(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
|
||||||
|
.init(label: "Closest station", description: "The nearest station by journey distance."),
|
||||||
|
.init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavouriteAndHelpCopyRemainPinned() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeySheetContent.favourite,
|
||||||
|
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,12 @@ final class FuelStoreBestValueThresholdTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class FuelStoreAlertModeTests: XCTestCase {
|
||||||
|
func testLegacyBestValueSelectionLoadsAsCheapest() {
|
||||||
|
FuelStore.saveAlertsMode(.bestValue)
|
||||||
|
defer { FuelStore.saveAlertsMode(.cheapest) }
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.loadAlertsMode(), .cheapest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -516,8 +516,10 @@ struct ContentView: View {
|
|||||||
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if favourite.isCheapest {
|
if favourite.isCheapest {
|
||||||
Text("TOP")
|
// Sentence case, to match every other surface: the watch
|
||||||
.font(.system(size: 9, weight: .bold))
|
// row is the same label as the phone's, not a different one.
|
||||||
|
Text("Top")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(fuel.color.opacity(0.18), in: Capsule())
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
|||||||
@@ -236,12 +236,100 @@ extension FuelSavingsResult {
|
|||||||
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full monetary verdict used by Station Details. Estimated confidence is a
|
||||||
|
/// trailing qualifier so the amount remains the first figure users scan.
|
||||||
|
func detailCapsuleHeadline() -> String {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? " (approx.)" : ""
|
||||||
|
switch status {
|
||||||
|
case .baseline:
|
||||||
|
return Self.baselineHeadline
|
||||||
|
case .breakEven:
|
||||||
|
return "\(Self.breakEvenHeadline)\(qualifier)"
|
||||||
|
case .worthTheDrive:
|
||||||
|
return "Save \(FuelSavingsFormat.money(netSavingGBP))\(qualifier)"
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return "Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more\(qualifier)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailCapsuleSpokenHeadline() -> String {
|
||||||
|
detailCapsuleHeadline().replacingOccurrences(of: "(approx.)", with: "(approximately)")
|
||||||
|
}
|
||||||
|
|
||||||
/// Row capsule copy. The baseline relationship is shown as secondary text
|
/// Row capsule copy. The baseline relationship is shown as secondary text
|
||||||
/// beside the capsule in the station listing and detail summary, so the
|
/// beside the capsule in the station listing and detail summary, so the
|
||||||
/// capsule itself contains only the verdict.
|
/// capsule itself contains only the verdict.
|
||||||
func capsuleHeadline() -> String {
|
func capsuleHeadline() -> String {
|
||||||
headline(compact: true)
|
headline(compact: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The station-listing capsule's verdict, split into the lines the capsule
|
||||||
|
/// draws.
|
||||||
|
///
|
||||||
|
/// The amount always takes its OWN line, so where the capsule breaks is
|
||||||
|
/// decided by the data, never by the width it was handed: the figure can
|
||||||
|
/// never straddle two lines, and a taller verdict grows the capsule downward
|
||||||
|
/// instead of widening the row's fixed price column.
|
||||||
|
struct ListingVerdict: Equatable {
|
||||||
|
/// "Saves", "Costs", "Break even" or "Closest station".
|
||||||
|
let headline: String
|
||||||
|
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
|
||||||
|
let amount: String?
|
||||||
|
/// "(approx.)" when the journey behind the figure was estimated.
|
||||||
|
let qualifier: String?
|
||||||
|
|
||||||
|
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
|
||||||
|
|
||||||
|
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
|
||||||
|
/// in the middle of a phrase when spoken.
|
||||||
|
var spokenText: String { lines.joined(separator: " ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verdict for the station listing rows — Stations and Favourites share one
|
||||||
|
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
|
||||||
|
/// which demonstrates the wording without inventing a figure.
|
||||||
|
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
|
||||||
|
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Saves"),
|
||||||
|
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Costs"),
|
||||||
|
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .breakEven:
|
||||||
|
// No figure: ±£0.05 is noise, so this stays a single line unless the
|
||||||
|
// estimate qualifier adds its own.
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: String(localized: "Break even"),
|
||||||
|
amount: nil,
|
||||||
|
qualifier: qualifier
|
||||||
|
)
|
||||||
|
case .baseline:
|
||||||
|
return ListingVerdict(
|
||||||
|
headline: Self.baselineHeadline,
|
||||||
|
amount: nil,
|
||||||
|
qualifier: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
|
||||||
|
private static func amountWithMore(_ value: Double) -> String {
|
||||||
|
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line verdict for voice and tests; the capsule itself renders
|
||||||
|
/// `listingVerdict()`'s lines separately.
|
||||||
|
func listingCapsuleHeadline() -> String {
|
||||||
|
listingVerdict().spokenText
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One station's journey distance, plus how it was obtained.
|
/// One station's journey distance, plus how it was obtained.
|
||||||
@@ -276,6 +364,82 @@ struct FuelSavingsRankedStation: Equatable {
|
|||||||
let pricePencePerLitre: Double
|
let pricePencePerLitre: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
|
||||||
|
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
|
||||||
|
/// each visual indicator without coupling the wording to SwiftUI layout.
|
||||||
|
///
|
||||||
|
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
|
||||||
|
/// are read as phrases, and a capsule set in all caps reads as shouting beside
|
||||||
|
/// the sentence-case copy in the rows around it. The tests pin these exact
|
||||||
|
/// strings, and `StationLabelKind` maps each one to its indicator, so the view
|
||||||
|
/// never matches on the literal text.
|
||||||
|
enum KeySheetContent {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let label: String
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static let priceRatings = [
|
||||||
|
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
|
||||||
|
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
|
||||||
|
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The same three verdicts the listing capsule draws, so the legend and the
|
||||||
|
/// rows cannot disagree on a word. "Costs" is the capsule's own headline.
|
||||||
|
static let savings = [
|
||||||
|
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
|
||||||
|
Item(label: "Break even", description: "The saving or extra cost is negligible."),
|
||||||
|
Item(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let stationLabels = [
|
||||||
|
Item(label: "Best value", description: "Meets your minimum saving after travel costs."),
|
||||||
|
Item(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
|
||||||
|
Item(label: "Closest station", description: "The nearest station by journey distance."),
|
||||||
|
Item(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
|
||||||
|
]
|
||||||
|
|
||||||
|
static let favourite = Item(
|
||||||
|
label: "Favourites",
|
||||||
|
description: "Star a station to save it to your Favourites."
|
||||||
|
)
|
||||||
|
|
||||||
|
static let helpTitle = "How does Best Value work?"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station label's meaning, independent of its copy.
|
||||||
|
///
|
||||||
|
/// The Key sheet used to dispatch its indicator on the label's literal text
|
||||||
|
/// (`case "BEST VALUE":`), so renaming a label silently dropped that row to the
|
||||||
|
/// fallback indicator. The enum makes the mapping explicit and lets the
|
||||||
|
/// compiler catch a missing case.
|
||||||
|
enum StationLabelKind: String, CaseIterable {
|
||||||
|
case bestValue
|
||||||
|
case cheapest
|
||||||
|
case closestStation
|
||||||
|
case top
|
||||||
|
|
||||||
|
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .bestValue: return String(localized: "Best value")
|
||||||
|
case .cheapest: return String(localized: "Cheapest")
|
||||||
|
case .closestStation: return FuelSavingsResult.baselineHeadline
|
||||||
|
case .top: return String(localized: "Top")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .bestValue: return "medal"
|
||||||
|
case .cheapest: return "tag"
|
||||||
|
case .closestStation: return "location.north.fill"
|
||||||
|
case .top: return "trophy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which recommendation badge a station listing row shows, if any.
|
/// Which recommendation badge a station listing row shows, if any.
|
||||||
///
|
///
|
||||||
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
/// Presentation only — no new rules: the BEST VALUE qualification reuses the
|
||||||
|
|||||||
@@ -701,10 +701,12 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadAlertsMode() -> AlertMode {
|
static func loadAlertsMode() -> AlertMode {
|
||||||
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
guard let raw = loadString(service: alertsModeKey),
|
||||||
return mode
|
let mode = AlertMode(rawValue: raw),
|
||||||
|
mode == .cheapest else {
|
||||||
|
return .cheapest
|
||||||
}
|
}
|
||||||
return .cheapest
|
return mode
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveAlertsMode(_ mode: AlertMode) {
|
static func saveAlertsMode(_ mode: AlertMode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user