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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user