Files
DriverVault/Phone App/lib/theme.dart
T
tajniak81andClaude Opus 5 a25b31842d 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 <noreply@anthropic.com>
2026-08-21 22:44:05 +02:00

300 lines
12 KiB
Dart

import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
/// DriverVault design tokens + Material theme, mirroring the web app's design
/// system (blue-led cool palette, Archivo + DM Mono type, 22px cards, soft
/// cool shadows). Every screen styles through this so light/dark theme for free.
class DriverVault {
DriverVault._();
// ---- Brand blue ramp (from the DriverVault mark) ----
static const brand900 = Color(0xFF0B1730);
static const brand800 = Color(0xFF0F1E3D);
static const brand700 = Color(0xFF1E40AF);
static const brand600 = Color(0xFF2563EB);
static const brand500 = Color(0xFF3B82F6);
static const brand400 = Color(0xFF60A5FA);
static const brand300 = Color(0xFF93C5FD);
static const brand100 = Color(0xFFE8F0FD);
// ---- Cool neutrals ----
static const ink900 = Color(0xFF0F1E3D);
static const ink600 = Color(0xFF3E4E68);
static const ink500 = Color(0xFF5C6B85);
static const ink400 = Color(0xFF7A8AA6);
static const ink200 = Color(0xFFD6DEEA);
static const ink100 = Color(0xFFE4E9F2);
static const ink50 = Color(0xFFEEF2F8);
static const ink25 = Color(0xFFF7F9FC);
// ---- Semantic status (car: OK / due / fault) ----
static const success = Color(0xFF1F8A5B);
static const warning = Color(0xFFD9822B);
static const danger = Color(0xFFDC2A45);
static const info = Color(0xFF2563EB);
// Light status tints
static const successSoft = Color(0xFFE1F3EA);
static const warningSoft = Color(0xFFFBEDDD);
static const dangerSoft = Color(0xFFFBE3E7);
static const infoSoft = Color(0xFFE8F0FD);
// Dark status tints (re-cut for dark surfaces)
static const successSoftDark = Color(0xFF12352A);
static const warningSoftDark = Color(0xFF3A2A16);
static const dangerSoftDark = Color(0xFF3A1620);
static const infoSoftDark = Color(0xFF122A4D);
// ---- Dark surfaces / text / borders ----
static const darkPage = Color(0xFF0B1730);
static const darkCard = Color(0xFF13233F);
static const darkSunken = Color(0xFF0F1E38);
static const darkBorder = Color(0xFF21324F);
static const darkBorderStrong = Color(0xFF2C3F5E);
static const darkTextStrong = Color(0xFFF2F6FC);
static const darkTextBody = Color(0xFFB7C4D9);
static const darkTextMuted = Color(0xFF7C8CA8);
static const darkAccent = Color(0xFF3B82F6);
static const radiusControl = 12.0;
static const radiusCard = 22.0;
static bool isDark(BuildContext c) => Theme.of(c).brightness == Brightness.dark;
// Brand tint used for avatars / info chips (adapts to theme).
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;
/// DM Mono style for data / units / labels.
static TextStyle mono(BuildContext c, {double size = 13, FontWeight weight = FontWeight.w500, Color? color}) =>
GoogleFonts.dmMono(
fontSize: size,
fontWeight: weight,
letterSpacing: 0.2,
color: color ?? Theme.of(c).textTheme.bodyMedium?.color,
);
static ThemeData theme(Brightness brightness) {
final dark = brightness == Brightness.dark;
final scheme = ColorScheme(
brightness: brightness,
primary: dark ? darkAccent : brand600,
onPrimary: Colors.white,
primaryContainer: dark ? const Color(0xFF17294A) : brand100,
onPrimaryContainer: dark ? brand300 : brand700,
secondary: dark ? brand400 : brand700,
onSecondary: Colors.white,
surface: dark ? darkCard : Colors.white,
onSurface: dark ? darkTextStrong : ink900,
surfaceContainerHighest: dark ? darkSunken : ink50,
onSurfaceVariant: dark ? darkTextBody : ink600,
outline: dark ? darkBorderStrong : ink200,
outlineVariant: dark ? darkBorder : ink100,
error: danger,
onError: Colors.white,
errorContainer: dark ? dangerSoftDark : dangerSoft,
onErrorContainer: danger,
);
final baseText = dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: dark ? darkPage : ink25,
textTheme: GoogleFonts.archivoTextTheme(baseText).apply(
bodyColor: dark ? darkTextBody : ink600,
displayColor: dark ? darkTextStrong : ink900,
),
appBarTheme: AppBarTheme(
backgroundColor: dark ? darkCard : Colors.white,
foregroundColor: dark ? darkTextStrong : ink900,
elevation: 0,
scrolledUnderElevation: 0.5,
centerTitle: false,
titleTextStyle: GoogleFonts.archivo(
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: dark ? darkTextStrong : ink900,
),
),
cardTheme: CardThemeData(
color: dark ? darkCard : Colors.white,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusCard),
side: BorderSide(color: dark ? darkBorder : ink100),
),
),
dividerColor: dark ? darkBorder : ink100,
dividerTheme: DividerThemeData(color: dark ? darkBorder : ink100, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: dark ? darkSunken : Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkBorderStrong : ink200),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusControl),
borderSide: BorderSide(color: dark ? darkAccent : brand600, width: 2),
),
labelStyle: TextStyle(color: dark ? darkTextMuted : ink500),
floatingLabelStyle: TextStyle(color: dark ? darkAccent : brand600),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: dark ? darkTextBody : ink600,
side: BorderSide(color: dark ? darkBorderStrong : ink200),
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: dark ? brand300 : brand700,
textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: dark ? darkAccent : brand600,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)),
),
chipTheme: ChipThemeData(
backgroundColor: dark ? darkSunken : ink50,
side: BorderSide(color: dark ? darkBorder : ink100),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: dark ? darkCard : Colors.white,
surfaceTintColor: Colors.transparent,
indicatorColor: dark ? const Color(0xFF17294A) : brand100,
elevation: 0,
height: 66,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return GoogleFonts.archivo(
fontSize: 11,
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final on = states.contains(WidgetState.selected);
return IconThemeData(
size: 24,
color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400),
);
}),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
);
}
}
/// The DriverVault "Fast Forward" mark — three forward-leaning rounded bars of
/// increasing height (skewX(-13°)), reading as acceleration / momentum.
class DriverVaultMark extends StatelessWidget {
final double size;
const DriverVaultMark({super.key, this.size = 32});
@override
Widget build(BuildContext context) {
// Geometry mirrors the canonical brand mark (drivervault-icon.svg): a 48-unit
// box with three 6-wide bars (heights 16/24/32), vertically centred and
// skewed -13deg. Keeping these proportions avoids the bars looking stretched.
final u = size / 48; // scale factor (brand viewBox is 48x48)
Widget bar(double h, Color c) => Container(
width: 6 * u,
height: h * u,
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3 * u)),
);
return SizedBox(
width: size,
height: size,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.skewX(-0.23), // -13deg, matches the SVG
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
bar(16, DriverVault.brand700),
SizedBox(width: 4 * u),
bar(24, DriverVault.brand500),
SizedBox(width: 4 * u),
bar(32, DriverVault.brand400),
],
),
),
);
}
}
/// Full DriverVault lockup: the mark + the Archivo italic-800 wordmark
/// ("Driver" in strong ink, "Vault" in brand blue).
class DriverVaultLogo extends StatelessWidget {
final double markSize;
final double fontSize;
const DriverVaultLogo({super.key, this.markSize = 32, this.fontSize = 24});
@override
Widget build(BuildContext context) {
final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900;
final brand = DriverVault.isDark(context) ? DriverVault.brand400 : DriverVault.brand700;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
DriverVaultMark(size: markSize),
SizedBox(width: markSize * 0.32),
Text.rich(
TextSpan(children: [
TextSpan(text: "Driver", style: TextStyle(color: strong)),
TextSpan(text: "Vault", style: TextStyle(color: brand)),
]),
style: GoogleFonts.archivo(
fontSize: fontSize,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
letterSpacing: -0.5,
),
),
],
);
}
}