Worth the Drive: calculation audit + listing/detail polish

Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):

- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
  metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
  used in the savings path. Effect ~2 ppm (invisible), but the conversions
  are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
  MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
  MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
  imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
  boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
  estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
  price), pump saving, round-trip extra distance clamped at zero, travel fuel
  priced at the candidate station, +/-5p break even, baseline labelled
  "Closest station", sorting on the unrounded net saving. The brief's "£0.18"
  for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
  "2.0 mi"), not a rounding bug in the money.

UI (uncommitted since d7a0ecb):

- Done bar drawn in the bottom safe area from keyboard notifications:
  .toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
  absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
  toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
  (the compact banner does not print it today; a future row layout would).

153 tests, 0 failures. Simulator and device builds succeed.
This commit is contained in:
FuelBoard Contributor
2026-10-11 17:19:10 +01:00
parent d7a0ecb79a
commit d0a89d97d5
10 changed files with 780 additions and 51 deletions
+60 -22
View File
@@ -1,5 +1,6 @@
import SwiftUI
import UIKit
import Combine
extension View {
/// Puts a Done button above the keyboard.
@@ -11,31 +12,68 @@ extension View {
/// `TextField(value:format:)` only commits the typed value when editing
/// ends — so the number never stuck either.
///
/// Apply ONCE per screen, to the list or form containing the fields: the
/// keyboard toolbar belongs to the focused field, so a single attachment
/// covers every field inside it. Attaching it to each field would show
/// several Done buttons at once.
/// WHY IT IS DRAWN HERE RATHER THAN AS `.toolbar(placement: .keyboard)`:
/// the keyboard-toolbar form is device-dependent. The same build showed the
/// Done button on the iPhone 15 Pro and NOT on the iPhone SE (both iOS
/// 27.0.1, same install, same binary), leaving the SE with no way to
/// dismiss or commit a numeric field. Drawing the bar in the screen's own
/// bottom safe area renders identically on every device, so the accessibility
/// of the numeric fields no longer depends on the accessory view appearing.
///
/// Apply ONCE per screen, to the list or form containing the fields: any
/// keyboard on that screen shows the bar, so attaching it to each field
/// would stack several Done buttons.
///
/// - Parameter action: clears the screen's focus state. Prefer this where a
/// `@FocusState` exists; the default resigns the current first responder,
/// which works for fields inside subviews that have no focus binding.
/// which works for fields inside subviews that have no focus binding (the
/// fill-up box in the station list is one).
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
if let action {
action()
} else {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
}
}
}
modifier(KeyboardDoneBar(action: action))
}
}
/// Shows a Done bar while a keyboard is up on this screen.
///
/// Visibility comes from the keyboard notifications rather than a `@FocusState`
/// so the bar also works for fields inside subviews that have no focus binding.
private struct KeyboardDoneBar: ViewModifier {
let action: (() -> Void)?
@State private var isKeyboardVisible = false
func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom, spacing: 0) {
if isKeyboardVisible {
HStack {
Spacer()
Button("Done") {
if let action {
action()
} else {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
isKeyboardVisible = false
}
.font(.body.weight(.semibold))
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(.bar)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
isKeyboardVisible = true
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
isKeyboardVisible = false
}
}
}