Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor 558f956d8d Update FuelBoard onboarding copy, title formatting, key sheet card wrapping, and best-value button placement 2026-10-14 16:22:31 +01:00
FuelBoard Contributor f04a0fe0f5 Key sheet: gate the How Best Value row on the feature, like the More info row
The More info row went inside the Best Value card's `if let fuelSavings`, but the
Key sheet row was left ungated, so it stayed visible with Best Value switched off.
It is now gated on `fuelSavingsSettings.isConfigured` — exactly the condition
`savingsForStation` uses, i.e. the same gate as the card — so the two rows appear
and disappear together.

Verified on the simulator by toggling Best Value in Settings and scrolling the Key
sheet to its end (four drags, sheet still up and its footer in view each time):
- OFF: the row is absent at every scroll position, and the More info sheet shows
  neither the card nor the row;
- ON: the row is present, and the More info card and its row are both back.
210 tests pass; Release builds.
2026-10-14 15:19:20 +01:00
FuelBoard Contributor f35f605b58 Onboarding welcome: the app icon stands on its own, with no circle behind it
The first onboarding screen showed the app icon inside AnimatedOnboardingIcon's
soft circle — a 10% tinted disc with an 18% ring around it. The wrapper now takes
`showsHalo`, defaulting to true so the other five pages and BestValueIntroView keep
theirs unchanged; the welcome page passes false.

Verified with before/after installs on the simulator:
- the icon's artwork is pixel-identical and unmoved (centre 603,661, 95 pt);
- the halo is gone — the tile's non-background footprint falls from the 118 pt
  ring to exactly the 96 pt tile, and the ring pixels at r=59 pt lose their tint;
- page 1 differs only by the status-bar clock, so the other pages are untouched.

The copy below the icon tightens by ~11 pt: the page block is centred between two
spacers and the icon's container no longer contributes 22 pt of height. The page
dots and the Continue button do not move. The icon keeps its soft accent shadow.
2026-10-14 15:02:29 +01:00
FuelBoard Contributor 7da277547b More info sheet: the copy confirmation reads "Postcode copied"
The capsule label was just "Copied", which reads oddly above a row whose hint is
"Copies the postcode". It is now "Postcode copied".

Still centred over the address row (centre x 603 of a 1206 px screen) and still a
pure overlay: the bands above and below the capsule are pixel-identical while it
is up (no reflow), and the row stays tappable straight through it.

The "Copied" localisation key goes with its only use; "Postcode copied" replaces it.
2026-10-14 14:52:07 +01:00
5 changed files with 79 additions and 52 deletions
+17
View File
@@ -78,6 +78,7 @@ struct FuelSavingsSummaryView: View {
/// Sizes the "extra return travel" caption in the user's own unit (the
/// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil
var body: some View {
switch availability {
@@ -166,6 +167,22 @@ struct FuelSavingsSummaryView: View {
.font(.caption2)
.foregroundStyle(.secondary)
}
if let showInfoAction {
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 4)
Button(action: showInfoAction) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
}
case .unavailable(let reason):
+35 -24
View File
@@ -98,7 +98,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
AnimatedOnboardingIcon(tint: .accentColor) {
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -108,10 +108,10 @@ struct OnboardingView: View {
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
}
.padding(.bottom, 28)
.padding(.bottom, 16)
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
.font(.largeTitle.bold())
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
.font(.title.bold())
.multilineTextAlignment(.center)
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
@@ -121,12 +121,13 @@ struct OnboardingView: View {
.padding(.horizontal, 32)
.padding(.top, 12)
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
Spacer()
}
@@ -298,18 +299,23 @@ struct OnboardingView: View {
OnboardingPrimaryButton(title: title, action: action)
}
private func featureRow(icon: String, text: String) -> some View {
HStack(spacing: 12) {
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 32)
Text(text)
.font(.subheadline)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.bold())
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 36)
.padding(.vertical, 10)
.padding(.vertical, 8)
}
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
@@ -393,6 +399,9 @@ struct AnimatedOnboardingIcon<Content: View>: View {
let tint: Color
var motion: Motion = .gentle
/// The soft circle behind the glyph. The welcome page turns it off: it shows
/// the app icon itself and reads better with no container around it.
var showsHalo: Bool = true
@ViewBuilder var content: Content
@State private var settled = false
@@ -402,16 +411,18 @@ struct AnimatedOnboardingIcon<Content: View>: View {
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
if showsHalo {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
}
content
.scaleEffect(iconScale)
+7 -13
View File
@@ -29,7 +29,7 @@ struct StationDetailView: View {
var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
/// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
/// the caption fades out on its own.
@State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>?
@@ -137,7 +137,7 @@ struct StationDetailView: View {
// The whole row is a copy control: it puts JUST the postcode
// on the clipboard so it can be pasted into another
// navigation app. The copy glyph sits ON the address line,
// and the "Copied" confirmation is a capsule that floats OVER
// and the "Postcode copied" confirmation is a capsule that floats OVER
// THIS ROW — it confirms the address, so it must not hover
// over the map. It is an overlay, so nothing moves while it is
// up, and `allowsHitTesting(false)` keeps the row tappable
@@ -172,7 +172,7 @@ struct StationDetailView: View {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Copied")
Text("Postcode copied")
}
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
@@ -238,18 +238,12 @@ struct StationDetailView: View {
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit
distanceUnit: distanceUnit,
showInfoAction: { showBestValueInfo = true }
)
} header: {
Text("Best Value")
}
// The explanation lives in its own sheet now, opened from this
// row. The row sits INSIDE the `if let` so it carries the same
// gate as the card above: no card, no row.
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
#if DEBUG
@@ -296,12 +290,12 @@ struct StationDetailView: View {
)
}
}
// The "Copied" fade-out task must not outlive the sheet.
// The "Postcode copied" fade-out task must not outlive the sheet.
.onDisappear { copyResetTask?.cancel() }
#if DEBUG
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
// clipboard value (read back with `xcrun simctl pbpaste`) and the
// "Copied" confirmation can be verified without a tap — simctl
// "Postcode copied" confirmation can be verified without a tap — simctl
// cannot drive taps into the Simulator.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
+16 -11
View File
@@ -267,14 +267,14 @@ struct StationsView: View {
keyCard(
icon: "checkmark.circle.fill", color: .green,
title: "Good price", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p")
threshold: "0–1.5p")
keyCard(
icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "Within 4p of the cheapest",
threshold: "≤4p")
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
threshold: ">1.5–4p")
keyCard(
icon: "exclamationmark.circle.fill", color: .red,
title: "Pricey", blurb: "More than 4p over the cheapest",
title: "Pricey", blurb: "More than 4p above the cheapest",
threshold: ">4p")
Rectangle()
.fill(Color(.separator))
@@ -306,10 +306,11 @@ struct StationsView: View {
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .yellow)
Text("Colours match each station's rating on the list")
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
.listRowBackground(Color.clear)
@@ -317,11 +318,14 @@ struct StationsView: View {
}
// The explanation is its own sheet now, opened from this
// row. Deliberately NOT gated by Best Value: the Key sheet
// is the legend and is reachable whatever the feature is
// doing.
Section {
HowBestValueRow { showBestValueInfo = true }
// row. Gated exactly like the More info row and the Best
// Value card itself: `isConfigured` is the same condition
// `savingsForStation` uses, so the row appears and
// disappears with the feature.
if fuelSavingsSettings.isConfigured {
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
}
.navigationTitle("Key")
@@ -374,6 +378,7 @@ struct StationsView: View {
Text(LocalizedStringKey(blurb))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
HStack(spacing: 6) {
+4 -4
View File
@@ -26,15 +26,15 @@
"Good price" = "Good price";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest";
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
"Pricey" = "Pricey";
"More than 4p over the cheapest" = "More than 4p over the cheapest";
"More than 4p above the cheapest" = "More than 4p above the cheapest";
"Top result for the current sort" = "Top result for the current sort";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
/* Star button in the More info sheet heading (accessibility labels) */
"Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
@@ -71,8 +71,8 @@
"Name" = "Name";
"Address" = "Address";
"Distance" = "Distance";
"Copied" = "Copied";
"Copies the postcode" = "Copies the postcode";
"Postcode copied" = "Postcode copied";
"Address: %@" = "Address: %@";
"Directions to %@" = "Directions to %@";
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";