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

143 lines
4.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;
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,
),
),
],
),
);
}
}