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;