diff --git a/FuelBoard/ImportantInformationSheet.swift b/FuelBoard/ImportantInformationSheet.swift new file mode 100644 index 0000000..4266358 --- /dev/null +++ b/FuelBoard/ImportantInformationSheet.swift @@ -0,0 +1,193 @@ +import SwiftUI + +/// "Important information" — FuelBoard's prices, availability, savings, journey +/// and liability disclaimer as ONE reusable sheet. +/// +/// The disclaimer is shown from more than one place, so the copy and its layout +/// live here once: a host adds an `ImportantInformationRow` (or the +/// `.importantInformationSheet(isPresented:)` modifier) and the wording can +/// never drift between entry points. +/// +/// One `Section` per topic, so every heading gets its own card: an accent +/// symbol beside a semibold title, with the secondary-coloured description +/// beneath. First host: the Stations tab's Key sheet (a row at the bottom). Copy +/// lives in `Localizable.strings`, keyed by the English source string, so the web +/// copy editor covers it. +struct ImportantInformationSheet: View { + @Environment(\.dismiss) private var dismiss + + /// Width of the symbol column that every heading's icon sits in. + /// + /// Symbols have wildly different ink widths — `car.side` is ~34 pt wide at + /// this size while `info.circle` is ~20 pt — so without a shared column each + /// heading's TEXT would start at a different x (measured: 180 px vs 233 px + /// at 3x), which is exactly the inconsistent alignment the brief rules out. + /// `@ScaledMetric` keeps the column in step with the icon font, so it still + /// can't overrun at large Dynamic Type sizes. + @ScaledMetric(relativeTo: .title3) private var iconSlot: CGFloat = 34 + + var body: some View { + NavigationStack { + List { + Section { + paragraphs( + "FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.", + "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump." + ) + } header: { + heading("Fuel Prices", systemImage: "sterlingsign.circle") + } + + Section { + paragraphs( + "A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.", + "We recommend checking availability before making a special journey." + ) + } header: { + heading("Fuel Availability", systemImage: "fuelpump.fill") + } + + Section { + paragraphs( + "Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.", + "Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost." + ) + } header: { + heading("Savings & Best Value", systemImage: "arrow.down.circle") + } + + Section { + paragraphs( + "Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.", + "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits." + ) + } header: { + heading("Journey Estimates", systemImage: "car.side") + } + + Section { + paragraphs( + "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." + ) + } header: { + heading("Before You Travel", systemImage: "checkmark.circle") + } + + Section { + paragraphs( + "FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.", + "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided." + ) + } header: { + heading("Disclaimer", systemImage: "info.circle") + } + } + // The brief asks for ~24 pt between topics; the list's own default is + // wider and unsettable, this pins it. + .listSectionSpacing(.custom(24)) + .navigationTitle("Important information") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.large]) + .presentationBackground(Color(UIColor.systemGroupedBackground)) + } + + /// A section heading: accent symbol + semibold title, laid out horizontally + /// and vertically centred, with the symbol at a consistent size in every + /// section. + /// + /// `.title3` is 20 pt at the default Dynamic Type size — inside the 18–20 pt + /// brief — and still scales with the user's text size, so the symbols stay in + /// proportion to the headings rather than drifting apart on large sizes. + /// + /// The symbol is decorative (the heading text already carries the meaning), + /// so it is hidden from VoiceOver to stop it being announced as a stray glyph. + private func heading(_ title: String, systemImage: String) -> some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(Color.accentColor) + // Same-width column for every symbol, so all six headings' + // text starts at one x regardless of how wide the glyph is. + .frame(width: iconSlot) + .accessibilityHidden(true) + Text(LocalizedStringKey(title)) + .font(.headline) + // `Color.primary`, not `.primary`: the bare ShapeStyle resolves + // against the List header's own secondary style and comes out + // grey — measured (152,152,159), i.e. identical to the body text, + // which throws away the heading/description hierarchy. + .foregroundStyle(Color.primary) + // Let a long heading wrap instead of truncating on a narrow + // device or at a large text size. + .fixedSize(horizontal: false, vertical: true) + } + } + + /// A card's paragraphs, stacked as prose rather than as separate `List` rows + /// so the card reads as one statement and the list draws no hairline between + /// a topic's own sentences. + /// + /// `LocalizedStringKey` is what makes a `String` parameter localisable — + /// `Text(someString)` would render verbatim and the copy editor could never + /// reach it. + @ViewBuilder + private func paragraphs(_ lines: String...) -> some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(LocalizedStringKey(line)) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } +} + +/// The row that opens `ImportantInformationSheet`. Reusable: drop it in any +/// `List` section. +/// +/// `.buttonStyle(.plain)` keeps it from becoming a row-wide tap target that +/// could swallow a neighbouring control (see the two-buttons-in-one-row rule). +struct ImportantInformationRow: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: "info.circle") + Text("Important information") + Spacer(minLength: 8) + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } +} + +extension View { + /// Presents the shared "Important information" disclaimer. One call site per + /// surface — the sheet itself is never duplicated. + func importantInformationSheet(isPresented: Binding) -> some View { + sheet(isPresented: isPresented) { ImportantInformationSheet() } + } +} + +#if DEBUG +/// QA hook support: `-showImportantInfo` opens the Key sheet and then the +/// disclaimer on top of it, so the copy can be screenshotted without a tap +/// (simctl has no tap command) — the same chained pattern as +/// `-showBestValueInfo`. The disclaimer's `.sheet` lives inside the Key sheet's +/// content, so it cannot present while the Key sheet is shut. +enum ImportantInformationQA { + static var isRequested: Bool { + ProcessInfo.processInfo.arguments.contains("-showImportantInfo") + } +} +#endif diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 3658722..1ba92dc 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -82,6 +82,9 @@ struct SettingsView: View { @State private var fillUpDraft: Double? @State private var versionTapCount = 0 @State private var lastVersionTap = Date.distantPast + /// The shared "Important information" disclaimer sheet, opened from the row + /// at the bottom of the tab. + @State private var showImportantInformation = false /// Widget diagnostics: the extension's last makeEntry beacon PER intent /// type (keychain) + the installed-widget inventory from WidgetCenter. @State private var smallWidgetDiag: String? @@ -607,11 +610,16 @@ struct SettingsView: View { Text("About") } + Section { + ImportantInformationRow { showImportantInformation = true } + } + } // Numeric entry must be escapable: `.decimalPad` has no Return key. .keyboardDoneButton { focusedSavingsField = nil } .scrollDismissesKeyboard(.interactively) .navigationTitle("Settings") + .importantInformationSheet(isPresented: $showImportantInformation) .onAppear { Task { await tipStore.load() } } diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 9098380..09351c4 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -39,6 +39,11 @@ struct StationsView: View { /// the Key sheet and from the More info sheet. @State private var showBestValueInfo = false + /// The shared "Important information" disclaimer sheet. It lives at the + /// bottom of the Key sheet here; the same sheet is reused wherever else the + /// disclaimer is needed. + @State private var showImportantInformation = false + /// Station picked for the More-info sheet (tap action = showDetails). @State private var detailStation: FuelStation? @@ -223,6 +228,17 @@ struct StationsView: View { showBestValueInfo = true } } + // QA hook: `-showImportantInfo` opens the Key sheet and then the + // shared disclaimer on top of it, so the copy can be + // screenshotted without a tap. Same chained requirement as + // `-showBestValueInfo` above. + if ImportantInformationQA.isRequested { + showKey = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_200_000_000) + showImportantInformation = true + } + } #endif } .onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in @@ -348,6 +364,10 @@ struct StationsView: View { HowBestValueRow { showBestValueInfo = true } } } + + Section { + ImportantInformationRow { showImportantInformation = true } + } } .navigationTitle("Key") .navigationBarTitleDisplayMode(.inline) @@ -358,6 +378,8 @@ struct StationsView: View { } // Nested sheet: the Key sheet stays behind the explanation. .sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() } + // Nested sheet: the Key sheet stays behind the disclaimer. + .importantInformationSheet(isPresented: $showImportantInformation) } .presentationDetents([.fraction(0.6)]) .presentationBackground(Color(UIColor.systemGroupedBackground)) diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 5abe8a0..8cde60e 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -249,3 +249,22 @@ "Uses the same distance as your station search." = "Uses the same distance as your station search."; "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel."; "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour."; +/* Important information — the shared disclaimer sheet (one card per heading) */ +"Important information" = "Important information"; +"Fuel Prices" = "Fuel Prices"; +"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app." = "FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app."; +"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump." = "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump."; +"Fuel Availability" = "Fuel Availability"; +"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type." = "A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type."; +"We recommend checking availability before making a special journey." = "We recommend checking availability before making a special journey."; +"Savings & Best Value" = "Savings & Best Value"; +"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings." = "Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings."; +"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost." = "Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost."; +"Journey Estimates" = "Journey Estimates"; +"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used." = "Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used."; +"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits." = "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits."; +"Before You Travel" = "Before You Travel"; +"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." = "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up."; +"Disclaimer" = "Disclaimer"; +"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations." = "FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations."; +"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided." = "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided.";