Settings: price display toggle — station sign (£129.9) vs pounds & pence (£1.299); display-only, all surfaces (list, widgets, Live Activity, Siri cards); Siri dialog stays speech-safe pounds

This commit is contained in:
FuelBoard Contributor
2026-08-14 15:33:26 +01:00
parent 819d2e6152
commit fd7fa5f9c9
10 changed files with 150 additions and 36 deletions
+41 -24
View File
@@ -10,6 +10,7 @@ struct ContentView: View {
@State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites() @State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@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()
@@ -137,31 +138,10 @@ struct ContentView: View {
var body: some View { var body: some View {
TabView { TabView {
StationsView( stationsTab
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") } .tabItem { Label("Stations", systemImage: "fuelpump.fill") }
FavouritesView( favouritesTab
favourites: refreshedFavourites,
selectedFuel: selectedFuel,
location: location,
distanceUnit: distanceUnit,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites
)
.tabItem { Label("Favourites", systemImage: "star.fill") } .tabItem { Label("Favourites", systemImage: "star.fill") }
AlertsView( AlertsView(
@@ -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 /// The Settings tab, extracted from `body` so the TabView expression stays
/// within the compiler's type-check budget. /// within the compiler's type-check budget.
private var settingsTab: some View { private var settingsTab: some View {
SettingsView( SettingsView(
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
@@ -455,6 +471,7 @@ struct StationRow: View {
let fuel: FuelType let fuel: FuelType
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double? let baselinePrice: Double?
let isTopResult: Bool let isTopResult: Bool
let isFavourite: Bool let isFavourite: Bool
@@ -536,7 +553,7 @@ struct StationRow: View {
Circle() Circle()
.fill(ragColor) .fill(ragColor)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(String(format: "£%.3f", price / 100)) Text(FuelStore.priceText(price, style: priceDisplayStyle))
.font(.title3.bold().monospaced()) .font(.title3.bold().monospaced())
.monospacedDigit() .monospacedDigit()
} }
+4
View File
@@ -13,6 +13,7 @@ struct FavouritesView: View {
let selectedFuel: FuelType let selectedFuel: FuelType
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in } var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop). /// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in } var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -56,12 +57,14 @@ struct FavouritesView: View {
selectedFuel: FuelType, selectedFuel: FuelType,
location: Coordinate?, location: Coordinate?,
distanceUnit: DistanceUnit, distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in }, onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) { onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) {
self.favourites = favourites self.favourites = favourites
self.selectedFuel = selectedFuel self.selectedFuel = selectedFuel
self.location = location self.location = location
self.distanceUnit = distanceUnit self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.onToggleFavourite = onToggleFavourite self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder self.onReorder = onReorder
_fuel = State(initialValue: selectedFuel) _fuel = State(initialValue: selectedFuel)
@@ -111,6 +114,7 @@ struct FavouritesView: View {
fuel: activeFuel, fuel: activeFuel,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: cheapestPrice, baselinePrice: cheapestPrice,
isTopResult: index == 0, isTopResult: index == 0,
isFavourite: activeFuelFavouriteIDs.contains(station.id), isFavourite: activeFuelFavouriteIDs.contains(station.id),
+12 -1
View File
@@ -14,6 +14,7 @@ import WidgetKit
/// relay plumbing. /// relay plumbing.
struct SettingsView: View { struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 var alertsFuel: FuelType = .e10
@@ -89,10 +90,20 @@ struct SettingsView: View {
FuelStore.saveDistanceUnit(newValue) FuelStore.saveDistanceUnit(newValue)
WidgetCenter.shared.reloadAllTimelines() 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: { } header: {
Text("Units") Text("Units")
} footer: { } 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 { Section {
+6 -4
View File
@@ -102,14 +102,15 @@ struct CheapestFuelIntent: AppIntent {
let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng)
let distanceText = FuelStore.loadDistanceUnit().format(distanceKM) 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( let freshness = SiriCheapestLookup.freshnessLabel(
updated: FuelStore.loadDataUpdated(), updated: FuelStore.loadDataUpdated(),
lastRefresh: FuelStore.loadLastRefresh() lastRefresh: FuelStore.loadLastRefresh()
) )
let summary = "\(station.name): \(priceText), \(distanceText)" let summary = "\(station.name): \(priceText), \(distanceText)"
let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" 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( return .result(
value: summary, 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( let freshness = SiriCheapestLookup.freshnessLabel(
updated: FuelStore.loadDataUpdated(), updated: FuelStore.loadDataUpdated(),
lastRefresh: FuelStore.loadLastRefresh() lastRefresh: FuelStore.loadLastRefresh()
@@ -270,7 +272,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
summary = "\(favourite.station.name): \(priceText)" 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( return .result(
value: summary, value: summary,
+2
View File
@@ -10,6 +10,7 @@ struct StationsView: View {
@Binding var sortMode: SortMode @Binding var sortMode: SortMode
@Binding var stationLimit: Int @Binding var stationLimit: Int
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double? let baselinePrice: Double?
let topStationID: String? let topStationID: String?
let location: Coordinate? let location: Coordinate?
@@ -99,6 +100,7 @@ struct StationsView: View {
fuel: selectedFuel, fuel: selectedFuel,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice, baselinePrice: baselinePrice,
isTopResult: station.id == topStationID, isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
+2 -1
View File
@@ -50,7 +50,8 @@
/* Settings tab */ /* Settings tab */
"Units" = "Units"; "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"; "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."; "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)"; "Test alert notification (real data)" = "Test alert notification (real data)";
@@ -561,3 +561,26 @@ final class SiriCheapestLookupTests: XCTestCase {
XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses") 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")
}
}
@@ -127,9 +127,10 @@ extension FuelBoardLiveActivityAttributes.ContentState {
FuelStore.loadDistanceUnit().format(distanceKM) 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 { var priceText: String {
String(format: "£%.3f", pricePence / 100) FuelStore.priceText(pricePence)
} }
/// Apple Maps directions URL to the pinned station. /// Apple Maps directions URL to the pinned station.
+2 -2
View File
@@ -395,7 +395,7 @@ struct FuelPriceWidgetView: View {
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
if let price = station.prices[entry.fuel] { if let price = station.prices[entry.fuel] {
Text(String(format: "£%.3f", price / 100)) Text(FuelStore.priceText(price))
.font(.system(size: 26, weight: .bold).monospaced()) .font(.system(size: 26, weight: .bold).monospaced())
.foregroundStyle(.green) .foregroundStyle(.green)
} }
@@ -455,7 +455,7 @@ struct FuelPriceWidgetView: View {
Circle() Circle()
.fill(ragColor(for: price, cheapest: cheapest)) .fill(ragColor(for: price, cheapest: cheapest))
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
Text(String(format: "£%.3f", price / 100)) Text(FuelStore.priceText(price))
.font(.caption.weight(.bold).monospaced()) .font(.caption.weight(.bold).monospaced())
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
+53
View File
@@ -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 // MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable { struct FuelStation: Identifiable, Codable, Equatable {
@@ -435,6 +454,40 @@ struct FuelStore {
saveString(unit.rawValue, service: distanceUnitKey) 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 // MARK: Debug mode
/// Hidden developer flag. NOT exposed in the UI: toggled by tapping the /// Hidden developer flag. NOT exposed in the UI: toggled by tapping the