Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa2485994 | ||
|
|
c6e252d194 | ||
|
|
7aaaffa1c5 | ||
|
|
7111ce38af | ||
|
|
ff6cbbe07d | ||
|
|
f261b0b10a | ||
|
|
1b6184c035 | ||
|
|
6a9b5df4f1 | ||
|
|
1cd4d86a69 |
@@ -533,7 +533,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -599,7 +599,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -663,7 +663,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -728,7 +728,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -826,7 +826,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -851,7 +851,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -875,7 +875,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -900,7 +900,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
|
||||
+153
-65
@@ -6,8 +6,8 @@ import SwiftUI
|
||||
/// versa). The Live Activity section also lives here — it mirrors this page's
|
||||
/// shape (toggle + fuel + distance) so the two "notify me while driving"
|
||||
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
|
||||
/// Settings → Debug; the user-facing page keeps a plain-language explainer
|
||||
/// plus a 0-stations hint.
|
||||
/// Settings → Debug; the user-facing page keeps short descriptions that follow
|
||||
/// the selected alert type and radius, plus a 0-stations hint.
|
||||
struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@@ -55,31 +55,40 @@ struct AlertsView: View {
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// Situational guidance for the currently-selected alert radius, shown
|
||||
/// under the picker so users know what each choice is best for. Labels
|
||||
/// mirror the picker row text (unit-aware) and explain the trade-off:
|
||||
/// smaller radii ping more often but later; larger radii ping rarely in
|
||||
/// town but earlier from afar.
|
||||
private var alertRadiusGuidance: String {
|
||||
/// The distance the geofence actually caps "Follow search" at, in miles
|
||||
/// (FuelStore stores the cap in km so the monitor and storage are unit-free).
|
||||
private var followSearchCapMiles: Int {
|
||||
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
|
||||
}
|
||||
|
||||
/// What the selected alert type actually does. Swapped with the picker so
|
||||
/// the copy can never describe the wrong mode.
|
||||
private var alertModeDescription: String {
|
||||
switch alertMode {
|
||||
case .cheapest:
|
||||
return String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description")
|
||||
case .bestValue:
|
||||
return String(localized: "Get notified when you're near a station that could save you money after travel costs.", comment: "Best Value proximity alert description")
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius the selected option really monitors, stated in the user's
|
||||
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
|
||||
/// station." "Follow search" mirrors the Stations-tab distance, which the
|
||||
/// geofence caps; said plainly, and only when the search distance really
|
||||
/// exceeds the cap, so the sentence never contradicts the picker row.
|
||||
private var alertRadiusDescription: String {
|
||||
switch alertChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
|
||||
case .fixed(let value):
|
||||
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
let body: String
|
||||
switch value {
|
||||
case 1:
|
||||
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
|
||||
case 2:
|
||||
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
|
||||
case 3:
|
||||
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
|
||||
case 5:
|
||||
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
|
||||
default:
|
||||
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
|
||||
let capped = stationLimit > followSearchCapMiles
|
||||
let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
|
||||
if capped {
|
||||
return String(format: String(localized: "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).", comment: "Proximity alert radius description, follow search capped"), label)
|
||||
}
|
||||
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
|
||||
return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label)
|
||||
case .fixed(let value):
|
||||
let label = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,49 +145,89 @@ struct AlertsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// The radius currently selected, in the user's unit — shown by the disabled
|
||||
/// row in place of the picker.
|
||||
private var alertRadiusValueLabel: String {
|
||||
switch alertChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
|
||||
case .fixed(let value):
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
}
|
||||
}
|
||||
|
||||
private var laDistanceValueLabel: String {
|
||||
switch laChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
|
||||
case .fixed(let miles):
|
||||
return distanceLabel(forMiles: miles)
|
||||
}
|
||||
}
|
||||
|
||||
/// An option row that belongs to a toggle. When the toggle is off the control
|
||||
/// is replaced by a static value row. The enclosing dependent group applies
|
||||
/// FuelBoard's shared disabled interaction and appearance once.
|
||||
@ViewBuilder
|
||||
private func optionRow<Content: View>(_ title: String,
|
||||
value: String,
|
||||
isEnabled: Bool,
|
||||
@ViewBuilder picker: () -> Content) -> some View {
|
||||
if isEnabled {
|
||||
picker()
|
||||
} else {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
Group {
|
||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||
value: favouriteDropFuel.displayName,
|
||||
isEnabled: favouriteDropEnabled) {
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||
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)
|
||||
.disabled(!favouriteDropEnabled)
|
||||
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
|
||||
} header: {
|
||||
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
||||
} footer: {
|
||||
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
|
||||
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
Group {
|
||||
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
|
||||
value: alertMode.displayName,
|
||||
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(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||
}
|
||||
}
|
||||
Text(alertRadiusGuidance)
|
||||
.font(.caption)
|
||||
Text(alertModeDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if alertMode == .bestValue {
|
||||
// The threshold is edited in Settings → Fuel Savings: it
|
||||
@@ -186,21 +235,43 @@ struct AlertsView: View {
|
||||
// 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(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||
value: fuel.displayName,
|
||||
isEnabled: enabled) {
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
}
|
||||
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
|
||||
value: alertRadiusValueLabel,
|
||||
isEnabled: enabled) {
|
||||
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(alertRadiusDescription)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if enabled && monitoredCount == 0 {
|
||||
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
||||
.font(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fuelBoardDisabledState(isEnabled: enabled)
|
||||
} header: {
|
||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||
} footer: {
|
||||
let modeDescription = alertMode == .bestValue
|
||||
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
|
||||
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
|
||||
Text(modeDescription)
|
||||
}
|
||||
|
||||
if enabled, let lastAlert {
|
||||
@@ -212,21 +283,38 @@ struct AlertsView: View {
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
Group {
|
||||
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
|
||||
value: liveActivityFuel.displayName,
|
||||
isEnabled: liveActivityEnabled) {
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||
optionRow(String(localized: "Distance", comment: "Distance picker title"),
|
||||
value: laDistanceValueLabel,
|
||||
isEnabled: liveActivityEnabled) {
|
||||
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||
}
|
||||
}
|
||||
}
|
||||
if liveActivityFollowsSearch {
|
||||
Text(String(localized: "Uses the same distance as your station search.", comment: "Live Activity distance description, follow search"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
|
||||
} header: {
|
||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||
} footer: {
|
||||
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
|
||||
|
||||
@@ -1846,8 +1846,8 @@ struct StationRow: View {
|
||||
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
|
||||
/// held per row rather than derived from the row's text — the point is that
|
||||
/// the hairline, the price axis and the star cannot move as the list scrolls.
|
||||
/// The price column takes the remainder, and its savings capsule scales down
|
||||
/// instead of widening the column and dragging the hairline with it.
|
||||
/// The price column takes the remainder, and its listing verdict wraps
|
||||
/// vertically instead of widening the column and dragging the hairline.
|
||||
///
|
||||
/// One value for both price styles: the star used to sit at the end of the
|
||||
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
|
||||
@@ -1857,11 +1857,6 @@ struct StationRow: View {
|
||||
/// in pounds & pence, and names keep the same room in both styles.
|
||||
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 {
|
||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||
@@ -2099,24 +2094,14 @@ struct StationRow: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
/// The savings capsule plus the comparison it is measured against. The
|
||||
/// capsule itself is the shared verdict view, so a row, the detail sheet and
|
||||
/// the compact summary cannot drift apart. The comparison label sits UNDER
|
||||
/// the capsule, trailing-aligned with it, at every width — beside it, a long
|
||||
/// verdict pushed the caption off the row and the pair no longer read as one
|
||||
/// statement.
|
||||
/// Listing-only verdict. It deliberately omits money and the repeated
|
||||
/// comparison caption; the full monetary result remains in More info.
|
||||
@ViewBuilder
|
||||
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if result.status == .baseline {
|
||||
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)
|
||||
} else {
|
||||
ListingFuelSavingsCapsule(result: result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ struct FavouritesView: View {
|
||||
}
|
||||
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.
|
||||
private var activeFuelFavouriteIDs: Set<String> {
|
||||
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
|
||||
@@ -145,8 +154,15 @@ struct FavouritesView: View {
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
Section {
|
||||
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 +187,12 @@ struct FavouritesView: View {
|
||||
} header: {
|
||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||
} 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 {
|
||||
|
||||
@@ -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 {
|
||||
@Binding var amount: FuelSavingsFillAmount
|
||||
|
||||
@State private var unitChangeNotice: String?
|
||||
@State private var showingComparisonHelp = false
|
||||
|
||||
private var range: ClosedRange<Double> {
|
||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||
@@ -26,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("How much are you filling up?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack {
|
||||
Text("How much fuel?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
showingComparisonHelp = true
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("About comparison modes")
|
||||
.popover(isPresented: $showingComparisonHelp) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Comparison modes")
|
||||
.font(.headline)
|
||||
Text("£: Compare how much fuel your budget buys at each station.")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Litres: Compare the cost of buying the same amount of fuel.")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.font(.footnote)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
.padding()
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
}
|
||||
Picker("Fill-up unit", selection: unitBinding) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
@@ -42,17 +67,9 @@ struct FuelSavingsAdjustmentView: View {
|
||||
.monospacedDigit()
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
}
|
||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||
Text("Adjust your fill-up amount for this visit. Your default won’t change.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
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 },
|
||||
set: { newUnit in
|
||||
guard newUnit != amount.unit else { return }
|
||||
// No one litre figure equals a budget at every candidate price.
|
||||
// A unit change starts a new, explicitly labelled comparison.
|
||||
// No one litre figure equals a budget at every candidate price,
|
||||
// so switching modes starts from that mode's configured default.
|
||||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||
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
|
||||
/// detail sheet and the compact summary.
|
||||
/// The colour-coded monetary verdict capsule used by the detail sheet and other
|
||||
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
|
||||
///
|
||||
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
||||
/// a Label lays its icon and title out across the row and leaves a visible gap
|
||||
@@ -93,7 +109,7 @@ struct FuelSavingsCapsule: View {
|
||||
HStack(spacing: compact ? 3 : 5) {
|
||||
Image(systemName: Self.symbol(for: result.status))
|
||||
.accessibilityHidden(true)
|
||||
Text(result.capsuleHeadline())
|
||||
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
|
||||
.lineLimit(1)
|
||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||||
// which the icon, its spacing and the 8pt padding either side
|
||||
@@ -129,4 +145,38 @@ struct FuelSavingsCapsule: View {
|
||||
case .baseline: return "location.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Amount-free, neutral verdict used by station listing rows only. Keeping this
|
||||
/// separate from `FuelSavingsCapsule` preserves the detail sheet's monetary,
|
||||
/// colour-coded verdict and comparison presentation.
|
||||
struct ListingFuelSavingsCapsule: View {
|
||||
let result: FuelSavingsResult
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Image(systemName: symbol)
|
||||
.accessibilityHidden(true)
|
||||
Text(result.listingCapsuleHeadline())
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.secondary.opacity(0.14)))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(result.listingCapsuleHeadline())
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.disabled(!fuelSavingsSettings.enabled)
|
||||
.fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
|
||||
} header: {
|
||||
Text("Fuel Savings")
|
||||
} footer: {
|
||||
@@ -582,7 +582,7 @@ struct SettingsView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(tipStore.purchaseInProgress)
|
||||
.fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
@@ -148,7 +148,7 @@ struct BestValueDetailCard: View {
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
FuelSavingsCapsule(result: result, compact: false)
|
||||
.accessibilityLabel(result.spokenHeadline())
|
||||
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
|
||||
comparison(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ struct StationsView: View {
|
||||
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 {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
|
||||
@@ -41,7 +41,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.";
|
||||
"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.";
|
||||
"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.";
|
||||
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
|
||||
|
||||
@@ -58,11 +58,9 @@
|
||||
"Radius" = "Radius";
|
||||
"%lld %@" = "%lld %@";
|
||||
"Follow search (%@)" = "Follow search (%@)";
|
||||
"When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing.";
|
||||
"No stations monitored yet — open the Stations tab to load prices first." = "No stations monitored yet — open the Stations tab to load prices first.";
|
||||
"Last alert" = "Last alert";
|
||||
"Live Activity" = "Live Activity";
|
||||
"Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities." = "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.";
|
||||
|
||||
/* Settings tab */
|
||||
"Units" = "Units";
|
||||
@@ -214,8 +212,6 @@
|
||||
/* Alerts and favourites localisation pass */
|
||||
"Cheapest favourite" = "Cheapest favourite";
|
||||
"Check now" = "Check now";
|
||||
"Price-drop alerts" = "Price-drop alerts";
|
||||
"Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise." = "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.";
|
||||
"Cheapest station" = "Cheapest station";
|
||||
"Alert type" = "Alert type";
|
||||
"Best Value" = "Best Value";
|
||||
@@ -224,19 +220,15 @@
|
||||
"Minimum saving %@ — change in Settings, Fuel Savings." = "Minimum saving %@ — change in Settings, Fuel Savings.";
|
||||
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
|
||||
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
|
||||
"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.";
|
||||
"Proximity alerts" = "Proximity alerts";
|
||||
"When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.";
|
||||
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";
|
||||
"Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips." = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.";
|
||||
"City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town." = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.";
|
||||
"Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns." = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.";
|
||||
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
|
||||
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
|
||||
"%@ — %@" = "%@ — %@";
|
||||
|
||||
/* 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.";
|
||||
"Best Value unavailable · location needed" = "Best Value unavailable · location needed";
|
||||
"your order" = "your order";
|
||||
@@ -246,3 +238,14 @@
|
||||
"May be outdated" = "May be outdated";
|
||||
"Fuel you'll receive" = "Fuel you'll receive";
|
||||
"Compared with your closest station." = "Compared with your closest station.";
|
||||
|
||||
/* Alerts tab — refined copy (sections, descriptions, limitations) */
|
||||
"Favourite price alerts" = "Favourite price alerts";
|
||||
"Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price." = "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.";
|
||||
"Get notified when you're near one of the cheapest stations for your chosen fuel." = "Get notified when you're near one of the cheapest stations for your chosen fuel.";
|
||||
"Get notified when you're near a station that could save you money after travel costs." = "Get notified when you're near a station that could save you money after travel costs.";
|
||||
"Get alerts when you're within %@ of a matching station." = "Get alerts when you're within %@ of a matching station.";
|
||||
"Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts)." = "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).";
|
||||
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
|
||||
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
|
||||
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
|
||||
|
||||
@@ -18,6 +18,49 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
)
|
||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
|
||||
func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves")
|
||||
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)")
|
||||
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more")
|
||||
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)")
|
||||
XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even")
|
||||
XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (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,
|
||||
price: Double?,
|
||||
lat: Double = 51.5,
|
||||
|
||||
@@ -236,12 +236,45 @@ extension FuelSavingsResult {
|
||||
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
|
||||
/// beside the capsule in the station listing and detail summary, so the
|
||||
/// capsule itself contains only the verdict.
|
||||
func capsuleHeadline() -> String {
|
||||
headline(compact: true)
|
||||
}
|
||||
|
||||
/// Short, amount-free verdict used only by station listing rows. The detail
|
||||
/// sheet continues to use `capsuleHeadline()` so its full monetary verdict is
|
||||
/// unchanged. Confidence follows the existing journey provenance exactly.
|
||||
func listingCapsuleHeadline() -> String {
|
||||
let approximate = journeyConfidence.isEstimated ? " (approx.)" : ""
|
||||
switch status {
|
||||
case .worthTheDrive: return "Saves\(approximate)"
|
||||
case .notWorthTheDrive: return "Costs more\(approximate)"
|
||||
case .breakEven: return "Break even\(approximate)"
|
||||
case .baseline: return Self.baselineHeadline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One station's journey distance, plus how it was obtained.
|
||||
|
||||
Reference in New Issue
Block a user