From fd7fa5f9c9c8884ad5aef61fb43feb376a07e3a0 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 14 Aug 2026 15:33:26 +0100 Subject: [PATCH] =?UTF-8?q?Settings:=20price=20display=20toggle=20?= =?UTF-8?q?=E2=80=94=20station=20sign=20(=C2=A3129.9)=20vs=20pounds=20&=20?= =?UTF-8?q?pence=20(=C2=A31.299);=20display-only,=20all=20surfaces=20(list?= =?UTF-8?q?,=20widgets,=20Live=20Activity,=20Siri=20cards);=20Siri=20dialo?= =?UTF-8?q?g=20stays=20speech-safe=20pounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FuelBoard/ContentView.swift | 69 ++++++++++++------- FuelBoard/FavouritesView.swift | 4 ++ FuelBoard/SettingsView.swift | 13 +++- FuelBoard/SiriShortcuts.swift | 10 +-- FuelBoard/StationsView.swift | 2 + FuelBoard/en.lproj/Localizable.strings | 3 +- .../FuelBoardSharedTests/FuelBoardTests.swift | 23 +++++++ .../FuelBoardLiveActivityView.swift | 5 +- FuelBoardWidgets/FuelPriceWidget.swift | 4 +- Shared/FuelStore.swift | 53 ++++++++++++++ 10 files changed, 150 insertions(+), 36 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 13a6e4e..64568af 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -10,6 +10,7 @@ struct ContentView: View { @State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() + @State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle() @State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @@ -137,32 +138,11 @@ struct ContentView: View { var body: some View { TabView { - StationsView( - stations: displayedStations, - totalCount: sortedStations.count, - isLoading: isLoading, - selectedFuel: $selectedFuel, - sortMode: $sortMode, - stationLimit: $stationLimit, - distanceUnit: distanceUnit, - baselinePrice: baselinePrice, - topStationID: topStationID, - location: location, - favouriteIDs: favouriteIDs, - onToggleFavourite: toggleFavourite, - onRefresh: { await refresh(force: true) } - ) - .tabItem { Label("Stations", systemImage: "fuelpump.fill") } + stationsTab + .tabItem { Label("Stations", systemImage: "fuelpump.fill") } - FavouritesView( - favourites: refreshedFavourites, - selectedFuel: selectedFuel, - location: location, - distanceUnit: distanceUnit, - onToggleFavourite: toggleFavourite, - onReorder: reorderFavourites - ) - .tabItem { Label("Favourites", systemImage: "star.fill") } + favouritesTab + .tabItem { Label("Favourites", systemImage: "star.fill") } AlertsView( enabled: $alertsEnabled, @@ -363,11 +343,47 @@ struct ContentView: View { ) } + /// The Stations tab, extracted from `body` so the TabView expression stays + /// within the compiler's type-check budget. + private var stationsTab: some View { + StationsView( + stations: displayedStations, + totalCount: sortedStations.count, + isLoading: isLoading, + selectedFuel: $selectedFuel, + sortMode: $sortMode, + stationLimit: $stationLimit, + distanceUnit: distanceUnit, + priceDisplayStyle: priceDisplayStyle, + baselinePrice: baselinePrice, + topStationID: topStationID, + location: location, + favouriteIDs: favouriteIDs, + onToggleFavourite: toggleFavourite, + onRefresh: { await refresh(force: true) } + ) + } + + /// The Favourites tab, extracted from `body` for the same type-check + /// budget reason. + private var favouritesTab: some View { + FavouritesView( + favourites: refreshedFavourites, + selectedFuel: selectedFuel, + location: location, + distanceUnit: distanceUnit, + priceDisplayStyle: priceDisplayStyle, + onToggleFavourite: toggleFavourite, + onReorder: reorderFavourites + ) + } + /// The Settings tab, extracted from `body` so the TabView expression stays /// within the compiler's type-check budget. private var settingsTab: some View { SettingsView( distanceUnit: $distanceUnit, + priceDisplayStyle: $priceDisplayStyle, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, testAlertResult: monitor.lastTestResult, @@ -455,6 +471,7 @@ struct StationRow: View { let fuel: FuelType let location: Coordinate? let distanceUnit: DistanceUnit + let priceDisplayStyle: PriceDisplayStyle let baselinePrice: Double? let isTopResult: Bool let isFavourite: Bool @@ -536,7 +553,7 @@ struct StationRow: View { Circle() .fill(ragColor) .frame(width: 8, height: 8) - Text(String(format: "£%.3f", price / 100)) + Text(FuelStore.priceText(price, style: priceDisplayStyle)) .font(.title3.bold().monospaced()) .monospacedDigit() } diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index 6921f04..401e242 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -13,6 +13,7 @@ struct FavouritesView: View { let selectedFuel: FuelType let location: Coordinate? let distanceUnit: DistanceUnit + let priceDisplayStyle: PriceDisplayStyle var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } /// Persists a reordered favourites array (after drag-and-drop). var onReorder: ([FavouriteEntry]) -> Void = { _ in } @@ -56,12 +57,14 @@ struct FavouritesView: View { selectedFuel: FuelType, location: Coordinate?, distanceUnit: DistanceUnit, + priceDisplayStyle: PriceDisplayStyle, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) { self.favourites = favourites self.selectedFuel = selectedFuel self.location = location self.distanceUnit = distanceUnit + self.priceDisplayStyle = priceDisplayStyle self.onToggleFavourite = onToggleFavourite self.onReorder = onReorder _fuel = State(initialValue: selectedFuel) @@ -111,6 +114,7 @@ struct FavouritesView: View { fuel: activeFuel, location: location, distanceUnit: distanceUnit, + priceDisplayStyle: priceDisplayStyle, baselinePrice: cheapestPrice, isTopResult: index == 0, isFavourite: activeFuelFavouriteIDs.contains(station.id), diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 420c043..427ff09 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -14,6 +14,7 @@ import WidgetKit /// relay plumbing. struct SettingsView: View { @Binding var distanceUnit: DistanceUnit + @Binding var priceDisplayStyle: PriceDisplayStyle /// The fuel + radius currently configured for alerts (mirrors the Alerts /// tab) so the test notification matches what real alerts will say. var alertsFuel: FuelType = .e10 @@ -89,10 +90,20 @@ struct SettingsView: View { FuelStore.saveDistanceUnit(newValue) WidgetCenter.shared.reloadAllTimelines() } + Picker("Price display", selection: $priceDisplayStyle) { + ForEach(PriceDisplayStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + .pickerStyle(.segmented) + .onChange(of: priceDisplayStyle) { _, newValue in + FuelStore.savePriceDisplayStyle(newValue) + WidgetCenter.shared.reloadAllTimelines() + } } header: { Text("Units") } footer: { - Text("Distances and search radii across the app, widget and alerts are shown in this unit.") + Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (£129.9) or in pounds and pence (£1.299).") } Section { diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 2166d42..5ae274a 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -102,14 +102,15 @@ struct CheapestFuelIntent: AppIntent { let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) let distanceText = FuelStore.loadDistanceUnit().format(distanceKM) - let priceText = String(format: "£%.3f", price / 100) + let priceText = FuelStore.priceText(price) + let priceSpoken = FuelStore.priceTextSpoken(price) let freshness = SiriCheapestLookup.freshnessLabel( updated: FuelStore.loadDataUpdated(), lastRefresh: FuelStore.loadLastRefresh() ) let summary = "\(station.name): \(priceText), \(distanceText)" let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" - let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceText), \(distanceText) away\(freshnessClause)." + let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceSpoken), \(distanceText) away\(freshnessClause)." return .result( value: summary, @@ -246,7 +247,8 @@ struct FavouriteFuelPriceIntent: AppIntent { ) } - let priceText = String(format: "£%.3f", price / 100) + let priceText = FuelStore.priceText(price) + let priceSpoken = FuelStore.priceTextSpoken(price) let freshness = SiriCheapestLookup.freshnessLabel( updated: FuelStore.loadDataUpdated(), lastRefresh: FuelStore.loadLastRefresh() @@ -270,7 +272,7 @@ struct FavouriteFuelPriceIntent: AppIntent { summary = "\(favourite.station.name): \(priceText)" } - let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceText)\(distanceClause)\(freshnessClause)." + let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceSpoken)\(distanceClause)\(freshnessClause)." return .result( value: summary, diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 7dc8257..6e6b491 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -10,6 +10,7 @@ struct StationsView: View { @Binding var sortMode: SortMode @Binding var stationLimit: Int let distanceUnit: DistanceUnit + let priceDisplayStyle: PriceDisplayStyle let baselinePrice: Double? let topStationID: String? let location: Coordinate? @@ -99,6 +100,7 @@ struct StationsView: View { fuel: selectedFuel, location: location, distanceUnit: distanceUnit, + priceDisplayStyle: priceDisplayStyle, baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 8812b12..e2caf50 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -50,7 +50,8 @@ /* Settings tab */ "Units" = "Units"; -"Distances and search radii across the app, widget and alerts are shown in this unit." = "Distances and search radii across the app, widget and alerts are shown in this unit."; +"Price display" = "Price display"; +"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (£129.9) or in pounds and pence (£1.299)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (£129.9) or in pounds and pence (£1.299)."; "Show introduction" = "Show introduction"; "Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts."; "Test alert notification (real data)" = "Test alert notification (real data)"; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index b88fd3b..6567758 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -561,3 +561,26 @@ final class SiriCheapestLookupTests: XCTestCase { XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses") } } + +// MARK: - Price display style + +final class PriceDisplayTests: XCTestCase { + func testStationSignStyle() { + XCTAssertEqual(FuelStore.priceText(129.9, style: .stationSign), "£129.9") + XCTAssertEqual(FuelStore.priceText(135.0, style: .stationSign), "£135.0") + XCTAssertEqual(FuelStore.priceText(249.9, style: .stationSign), "£249.9") + } + + func testPoundsPenceStyle() { + XCTAssertEqual(FuelStore.priceText(129.9, style: .poundsPence), "£1.299") + XCTAssertEqual(FuelStore.priceText(135.0, style: .poundsPence), "£1.350") + XCTAssertEqual(FuelStore.priceText(100.9, style: .poundsPence), "£1.009") + } + + func testSpokenAlwaysPounds() { + // Siri would read "£129.9" as "one hundred and twenty-nine pounds" — + // the spoken form must always be pounds regardless of display style. + XCTAssertEqual(FuelStore.priceTextSpoken(129.9), "£1.299") + XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009") + } +} diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 0ed4754..deda016 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -127,9 +127,10 @@ extension FuelBoardLiveActivityAttributes.ContentState { FuelStore.loadDistanceUnit().format(distanceKM) } - /// £ price string from pence, e.g. 161.9 -> "£1.619". + /// Price string from pence, in the user's chosen display style + /// (station sign "£129.9" or pounds "£1.299"). var priceText: String { - String(format: "£%.3f", pricePence / 100) + FuelStore.priceText(pricePence) } /// Apple Maps directions URL to the pinned station. diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 673aa7b..1f346d3 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -395,7 +395,7 @@ struct FuelPriceWidgetView: View { .font(.headline) .lineLimit(1) if let price = station.prices[entry.fuel] { - Text(String(format: "£%.3f", price / 100)) + Text(FuelStore.priceText(price)) .font(.system(size: 26, weight: .bold).monospaced()) .foregroundStyle(.green) } @@ -455,7 +455,7 @@ struct FuelPriceWidgetView: View { Circle() .fill(ragColor(for: price, cheapest: cheapest)) .frame(width: 6, height: 6) - Text(String(format: "£%.3f", price / 100)) + Text(FuelStore.priceText(price)) .font(.caption.weight(.bold).monospaced()) .foregroundStyle(.primary) } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 9628140..b726608 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -143,6 +143,25 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable { } } +/// Display style for fuel prices. Internally prices are always stored and +/// computed in pence-per-litre (GOV.UK's unit, e.g. 129.9); the style only +/// affects RENDERING: the station-sign convention shows the pence figure +/// ("£129.9", what a forecourt sign shows), pounds & pence shows the +/// converted value ("£1.299"). Calculations never see this. +enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable { + case stationSign // £129.9 — matches the sign + case poundsPence // £1.299 + + var id: String { rawValue } + + var displayName: String { + switch self { + case .stationSign: return "Station sign (£129.9)" + case .poundsPence: return "Pounds & pence (£1.299)" + } + } +} + // MARK: - Station model struct FuelStation: Identifiable, Codable, Equatable { @@ -435,6 +454,40 @@ struct FuelStore { saveString(unit.rawValue, service: distanceUnitKey) } + // MARK: Price display — station-sign (£129.9) vs pounds & pence (£1.299). + // Prices are always stored/computed in pence-per-litre; this style only + // changes how they are RENDERED, so it can never affect calculations. + // Default station sign = the forecourt convention. + + static let priceDisplayStyleKey = "fuelboard.priceDisplayStyle" + + static func loadPriceDisplayStyle() -> PriceDisplayStyle { + if let raw = loadString(service: priceDisplayStyleKey), let style = PriceDisplayStyle(rawValue: raw) { + return style + } + return .stationSign + } + + static func savePriceDisplayStyle(_ style: PriceDisplayStyle) { + saveString(style.rawValue, service: priceDisplayStyleKey) + } + + /// Render a pence-per-litre price per the saved style: + /// stationSign -> "£129.9" (sign convention), poundsPence -> "£1.299". + static func priceText(_ pence: Double, style: PriceDisplayStyle? = nil) -> String { + switch style ?? loadPriceDisplayStyle() { + case .stationSign: return String(format: "£%.1f", pence) + case .poundsPence: return String(format: "£%.3f", pence / 100) + } + } + + /// Speech-safe pounds form for Siri dialogs — Siri would read "£129.9" + /// aloud as "one hundred and twenty-nine pounds", so the SPOKEN answer + /// always uses pounds regardless of the display style. + static func priceTextSpoken(_ pence: Double) -> String { + String(format: "£%.3f", pence / 100) + } + // MARK: Debug mode /// Hidden developer flag. NOT exposed in the UI: toggled by tapping the