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