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
+43 -26
View File
@@ -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()
}
+4
View File
@@ -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),
+12 -1
View File
@@ -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 {
+6 -4
View File
@@ -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,
+2
View File
@@ -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),
+2 -1
View File
@@ -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)";
@@ -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")
}
}
@@ -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.
+2 -2
View File
@@ -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)
}
+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
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