Compare commits

..
12 changed files with 241 additions and 80 deletions
+8 -8
View File
@@ -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";
+29 -24
View File
@@ -166,9 +166,8 @@ struct AlertsView: View {
}
/// 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
/// because an inline-style picker in a List still expands its options when
/// tapped while disabled.
/// 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,
@@ -182,7 +181,6 @@ struct AlertsView: View {
Spacer()
Text(value)
}
.foregroundStyle(.tertiary)
}
}
@@ -191,28 +189,30 @@ struct AlertsView: View {
List {
Section {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
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)
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)
.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)
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
} header: {
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
}
Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
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) {
@@ -265,9 +265,11 @@ struct AlertsView: View {
.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)
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"))
}
@@ -281,7 +283,8 @@ struct AlertsView: View {
Section {
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,
isEnabled: liveActivityEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
@@ -305,9 +308,11 @@ struct AlertsView: View {
.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)
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"))
}
+7 -22
View File
@@ -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)
}
}
+24 -3
View File
@@ -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 {
+12
View File
@@ -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)
}
}
+68 -18
View File
@@ -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"
}
}
}
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
}
}
+7
View File
@@ -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()
+7 -2
View File
@@ -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.";
@@ -223,7 +223,12 @@
"Proximity alerts" = "Proximity alerts";
/* 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";
@@ -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,
+33
View File
@@ -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.