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:
+43
-26
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user