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.
80 lines
3.5 KiB
Swift
80 lines
3.5 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import Combine
|
|
|
|
extension View {
|
|
/// Puts a Done button above the keyboard.
|
|
///
|
|
/// The numeric fields (Settings → Average fuel economy and Typical
|
|
/// fill-up, and the Worth the Drive fill-up box in the station list) use
|
|
/// `.decimalPad`, which has NO Return key. Without this there is no way to
|
|
/// dismiss the keyboard once such a field is tapped, and a
|
|
/// `TextField(value:format:)` only commits the typed value when editing
|
|
/// ends — so the number never stuck either.
|
|
///
|
|
/// 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 (the
|
|
/// fill-up box in the station list is one).
|
|
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
|
|
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
|
|
}
|
|
}
|
|
}
|