From a25b31842dd15728a0eb70daae7bbcef2313558b Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:44:05 +0200 Subject: [PATCH] Round the connected service's readings; keep sheet buttons off the nav bar Both found by driving the installed app on a phone rather than by reading the code, which is worth noting: the second one is invisible in a simulator with gesture navigation turned off. The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric range (A/C off) 103.9 km". The long number is a reading converted out of miles: headlineMetrics multiplied by 1.609344 and printed whatever came out, so a range estimate claimed to know the distance to the metre, and the two readings disagreed about their own precision on the same card. Distances now keep one decimal and percentages none, applied by the reading's kind rather than by whether it was converted - a provider reporting 99.744 km natively gets the same treatment. Anything else is left alone, because without knowing what it measures there is no safe place to cut. The odometer already rounded to a whole number on its own path; this only changes the headline readings. The Add-user sheet's "Create user" button sat underneath the system navigation bar. Every one of these sheets padded its bottom with viewInsets.bottom, which is the keyboard - correct while typing and wrong the rest of the time, because with the keyboard down that inset is zero and the navigation bar is still there. They take the larger of the keyboard and the navigation bar now, since a raised keyboard covers the bar and the two must not be added. One helper on DriverVault rather than the same expression in six files, which is how the six drifted into being identical and identically wrong. Verified: go build, go vet and go test ./... pass, with a new test covering the conversion (62 mi reads 99.8 km), a native over-precise reading, a percentage, and the odometer's whole number surviving. flutter analyze clean, 21 tests pass, and the rebuilt release APK was installed on the phone - the Create user button now sits clear of the navigation bar, where the screenshot that prompted this showed it clipped. Not verified: the rounding is not visible on the phone yet. It talks to a deployed API Server that has not been rebuilt from this commit, so that tab will keep reading 99.744 until the server is redeployed. Co-Authored-By: Claude Opus 5 --- API Server/internal/api/vehicleproviders.go | 20 ++++++++++- .../internal/api/vehicleproviders_test.go | 33 +++++++++++++++++++ Phone App/lib/screens/admin_users_screen.dart | 2 +- Phone App/lib/screens/car_detail_screen.dart | 6 ++-- Phone App/lib/screens/car_form_sheet.dart | 2 +- Phone App/lib/screens/record_form_sheets.dart | 2 +- Phone App/lib/theme.dart | 13 ++++++++ 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/API Server/internal/api/vehicleproviders.go b/API Server/internal/api/vehicleproviders.go index 1047a18..893f533 100644 --- a/API Server/internal/api/vehicleproviders.go +++ b/API Server/internal/api/vehicleproviders.go @@ -650,6 +650,24 @@ var headlineMetricSpecs = []metricSpec{ {key: "evRangeWithAc", keys: []string{"evRangeWithAc"}, unit: "km", distance: true}, } +// roundForDisplay trims a reading to the precision it actually has. +// +// A range converted from miles arrives as 99.744 km, which claims to know the +// remaining range to the metre and reads as false precision beside the 103.9 its +// untouched twin reports. Distances keep one decimal and percentages none; +// anything else is left alone, because without knowing what it measures there is +// no safe place to cut. +func roundForDisplay(n float64, spec metricSpec) float64 { + switch { + case spec.distance: + return math.Round(n*10) / 10 + case spec.unit == "%": + return math.Round(n) + default: + return n + } +} + // unmeasuredMetricKeys are the headline readings that are not measures — a state // and a pair of coordinates — picked out separately below. var unmeasuredMetricKeys = []string{"chargingStatus", "location"} @@ -671,7 +689,7 @@ func headlineMetrics(trees []any) []providerMetric { } else if unit != "" && spec.unit != "%" { display = unit } - out = append(out, providerMetric{Key: spec.key, Value: formatNumber(n), Unit: display}) + out = append(out, providerMetric{Key: spec.key, Value: formatNumber(roundForDisplay(n, spec)), Unit: display}) break } } diff --git a/API Server/internal/api/vehicleproviders_test.go b/API Server/internal/api/vehicleproviders_test.go index 8968b5a..d5de038 100644 --- a/API Server/internal/api/vehicleproviders_test.go +++ b/API Server/internal/api/vehicleproviders_test.go @@ -194,6 +194,39 @@ func TestHeadlineMetrics(t *testing.T) { } } +// A reading converted out of miles must not claim to know the range to the +// metre. 62 mi is 99.779136 km exactly; the tab shows 99.8, the way the same +// figure reported in km already would read. +func TestHeadlineMetricsRoundsConvertedReadings(t *testing.T) { + trees := []any{ + decode(t, `{"payload": {"odometer": {"value": 12000, "unit": "mi"}, + "evRange": {"value": 62, "unit": "mi"}, + "evRangeWithAc": {"value": 99.744, "unit": "km"}, + "batteryLevel": 23.4}}`), + } + got := map[string]providerMetric{} + for _, m := range headlineMetrics(trees) { + got[m.Key] = m + } + + if got["evRange"].Value != "99.8" || got["evRange"].Unit != "km" { + t.Errorf("evRange = %+v, want 99.8 km", got["evRange"]) + } + // Rounding is by the reading's kind, not by whether it was converted: a + // provider reporting km with too many decimals gets the same treatment. + if got["evRangeWithAc"].Value != "99.7" { + t.Errorf("evRangeWithAc = %+v, want 99.7", got["evRangeWithAc"]) + } + // A whole number stays whole rather than gaining a ".0". + if got["odometer"].Value != "19312.1" { + t.Errorf("odometer = %+v, want 19312.1", got["odometer"]) + } + // A battery percentage is a whole number; tenths of a percent are noise. + if got["batteryLevel"].Value != "23" || got["batteryLevel"].Unit != "%" { + t.Errorf("batteryLevel = %+v, want 23 %%", got["batteryLevel"]) + } +} + func TestNormalizeProviderFuelType(t *testing.T) { cases := map[string]string{ "HV": "hybrid", diff --git a/Phone App/lib/screens/admin_users_screen.dart b/Phone App/lib/screens/admin_users_screen.dart index 2966ebf..f0dce16 100644 --- a/Phone App/lib/screens/admin_users_screen.dart +++ b/Phone App/lib/screens/admin_users_screen.dart @@ -331,7 +331,7 @@ class _CreateUserSheetState extends State<_CreateUserSheet> { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart index 85cdab8..acdaf3a 100644 --- a/Phone App/lib/screens/car_detail_screen.dart +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -1691,7 +1691,7 @@ class _ShareSheetState extends State<_ShareSheet> { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -1956,7 +1956,7 @@ class _ServiceSheetState extends State<_ServiceSheet> { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -2139,7 +2139,7 @@ class _PartSheetState extends State<_PartSheet> { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/Phone App/lib/screens/car_form_sheet.dart b/Phone App/lib/screens/car_form_sheet.dart index 4271872..62788f2 100644 --- a/Phone App/lib/screens/car_form_sheet.dart +++ b/Phone App/lib/screens/car_form_sheet.dart @@ -187,7 +187,7 @@ class _CarFormSheetState extends State { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: SingleChildScrollView( child: Column( diff --git a/Phone App/lib/screens/record_form_sheets.dart b/Phone App/lib/screens/record_form_sheets.dart index 6027aae..a7f3226 100644 --- a/Phone App/lib/screens/record_form_sheets.dart +++ b/Phone App/lib/screens/record_form_sheets.dart @@ -47,7 +47,7 @@ class _SheetScaffold extends StatelessWidget { left: 16, right: 16, top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, + bottom: DriverVault.sheetBottomInset(context), ), child: SingleChildScrollView( child: Column( diff --git a/Phone App/lib/theme.dart b/Phone App/lib/theme.dart index 0cdeea1..cf00588 100644 --- a/Phone App/lib/theme.dart +++ b/Phone App/lib/theme.dart @@ -65,6 +65,19 @@ class DriverVault { static Color brandTint(BuildContext c) => isDark(c) ? const Color(0xFF17294A) : brand100; static Color brandOnTint(BuildContext c) => isDark(c) ? brand300 : brand700; + /// Bottom padding for a modal bottom sheet. + /// + /// The keyboard and the system navigation bar both eat into the bottom of a + /// sheet, and padding for only one of them puts the save button under the + /// other. They do not add up — a raised keyboard covers the navigation bar — + /// so it is the larger of the two, plus the sheet's own margin. + static double sheetBottomInset(BuildContext c) { + final media = MediaQuery.of(c); + final keyboard = media.viewInsets.bottom; + final systemBar = media.viewPadding.bottom; + return (keyboard > systemBar ? keyboard : systemBar) + 16; + } + /// Muted secondary text colour (replaces ad-hoc Colors.grey). static Color muted(BuildContext c) => isDark(c) ? darkTextMuted : ink400;