MotorSignal-App/lib/features/dashboard/widgets/speed_readout.dart

118 lines
3.5 KiB
Dart

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;
const SpeedTelemetryObservation({
required this.rawGnssSpeedMph,
required this.validatedSpeedMph,
required this.filteredSpeedMph,
required this.displayedSpeedMph,
});
}
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 speed = ref.watch(
telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0),
);
final clampedSpeed = speed < 0.0 ? 0.0 : speed;
if (onObservation != null) {
onObservation!(SpeedTelemetryObservation(
rawGnssSpeedMph: clampedSpeed,
validatedSpeedMph: clampedSpeed,
filteredSpeedMph: clampedSpeed,
displayedSpeedMph: clampedSpeed,
));
}
final isDark = Theme.of(context).brightness == Brightness.dark;
final speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
final unitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
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(
clampedSpeed.toStringAsFixed(0),
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: fontSize,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
height: 0.95,
color: speedColor,
shadows: isDark
? [
Shadow(
color: AppColors.cyberCyan.withValues(alpha: 0.4),
blurRadius: 16,
),
]
: null,
),
),
),
const SizedBox(height: 2),
Text(
unit,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: unitSize,
fontWeight: FontWeight.w800,
fontStyle: FontStyle.italic,
letterSpacing: 2.5,
color: unitColor,
),
),
],
),
);
}
}