import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../../services/telemetry_service.dart'; /// Structured telemetry observation model for speedometer pipeline. /// /// Designed so future forensic instrumentation can observe raw GNSS speed, /// validated speed, filtered speed, and displayed speed without rewriting UI. class SpeedTelemetryObservation { final double rawGnssSpeedMph; final double validatedSpeedMph; final double filteredSpeedMph; final double displayedSpeedMph; final bool isFresh; const SpeedTelemetryObservation({ required this.rawGnssSpeedMph, required this.validatedSpeedMph, required this.filteredSpeedMph, required this.displayedSpeedMph, this.isFresh = true, }); } enum SpeedReadoutSize { compact, regular, hero, } /// Authoritative isolated leaf widget for current speed readout. /// /// Subscribes strictly to speed changes via [telemetryStreamProvider] /// to prevent outer dashboard reconstructions at 20 Hz. class SpeedReadout extends ConsumerWidget { final SpeedReadoutSize size; final String unit; final void Function(SpeedTelemetryObservation observation)? onObservation; const SpeedReadout({ super.key, this.size = SpeedReadoutSize.regular, this.unit = 'MPH', this.onObservation, }); @override Widget build(BuildContext context, WidgetRef ref) { final speedState = ref.watch( telemetryStreamProvider.select((s) { final sample = s.value; if (sample == null) { // Pre-telemetry initial state (before first tick) return (speed: 0.0, isFresh: true, isInitial: true, rawSpeed: null); } return ( speed: sample.freshSpeed, isFresh: sample.isSpeedFresh, isInitial: false, rawSpeed: sample.speed, ); }), ); final isDark = Theme.of(context).brightness == Brightness.dark; final baseUnitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; final String speedText; final Color speedColor; final Color unitColor; if (speedState.isInitial || (speedState.rawSpeed == null && !speedState.isFresh)) { speedText = '0'; speedColor = isDark ? Colors.white : AppColors.lightOnSurface; unitColor = baseUnitColor; } else if (!speedState.isFresh) { // Stale or unavailable GNSS fix during active motion: display '--' to reflect truthful uncertainty speedText = '--'; speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38); unitColor = baseUnitColor.withValues(alpha: 0.5); } else { final clampedSpeed = (speedState.speed ?? 0.0) < 0.0 ? 0.0 : (speedState.speed ?? 0.0); speedText = clampedSpeed.toStringAsFixed(0); speedColor = isDark ? Colors.white : AppColors.lightOnSurface; unitColor = baseUnitColor; } if (onObservation != null) { final currentMph = speedState.speed ?? 0.0; onObservation!(SpeedTelemetryObservation( rawGnssSpeedMph: currentMph, validatedSpeedMph: currentMph, filteredSpeedMph: currentMph, displayedSpeedMph: currentMph, isFresh: speedState.isFresh, )); } final (double fontSize, double unitSize) = switch (size) { SpeedReadoutSize.compact => (54.0, 14.0), SpeedReadoutSize.regular => (96.0, 18.0), SpeedReadoutSize.hero => (140.0, 24.0), }; return RepaintBoundary( child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ FittedBox( fit: BoxFit.scaleDown, child: Text( speedText, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: fontSize, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, height: 0.95, color: speedColor, ), ), ), const SizedBox(height: 2), Text( unit, style: TextStyle( fontFamily: AppTypography.fontTelemetry, fontSize: unitSize, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic, letterSpacing: 2.5, color: unitColor, ), ), ], ), ); } }