From a362e7311ff67e0dc71a2b6b9172b6f41c9b0b2e Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 5 Sep 2026 08:42:54 -0400 Subject: [PATCH] feat: add performance launch experience --- lib/core/router.dart | 5 + .../widgets/performance_summary.dart | 99 +- .../performance_launch_screen.dart | 1446 +++++++++++++++++ .../performance_launch_state_machine.dart | 2 + lib/features/records/records_screen.dart | 26 + test/performance_launch_screen_test.dart | 637 ++++++++ 6 files changed, 2169 insertions(+), 46 deletions(-) create mode 100644 lib/features/performance/performance_launch_screen.dart create mode 100644 test/performance_launch_screen_test.dart diff --git a/lib/core/router.dart b/lib/core/router.dart index 83f9af6..020a811 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -25,6 +25,7 @@ import '../features/group_rides/group_ride_detail_screen.dart'; import '../features/group_rides/live_group_ride_screen.dart'; import '../features/routes/route_planner_screen.dart'; import '../features/routes/route_detail_screen.dart'; +import '../features/performance/performance_launch_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -215,5 +216,9 @@ final appRouter = GoRouter( return RouteDetailScreen(routeId: id); }, ), + GoRoute( + path: '/launch', + builder: (context, state) => const PerformanceLaunchScreen(), + ), ], ); diff --git a/lib/features/dashboard/widgets/performance_summary.dart b/lib/features/dashboard/widgets/performance_summary.dart index 31c8321..d3a8dc4 100644 --- a/lib/features/dashboard/widgets/performance_summary.dart +++ b/lib/features/dashboard/widgets/performance_summary.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import '../../performance/performance_timer_service.dart'; @@ -23,28 +24,31 @@ class PerformanceSummary extends ConsumerWidget { final elapsed = perfData.currentElapsedSeconds ?? 0.0; final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.telemetryGreen.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: AppColors.telemetryGreen, width: 1.0), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14), - const SizedBox(width: 5), - Text( - '$targetStr: ${elapsed.toStringAsFixed(2)}s', - style: const TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 11, - fontWeight: FontWeight.w800, - color: AppColors.telemetryGreen, + return GestureDetector( + onTap: () => context.push('/launch'), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.telemetryGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.telemetryGreen, width: 1.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14), + const SizedBox(width: 5), + Text( + '$targetStr: ${elapsed.toStringAsFixed(2)}s', + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.telemetryGreen, + ), ), - ), - ], + ], + ), ), ); } @@ -54,31 +58,34 @@ class PerformanceSummary extends ConsumerWidget { } final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isDark ? AppColors.darkOutline : AppColors.lightOutline, - width: 0.8, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber), - const SizedBox(width: 4), - Text( - '$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 10, - fontWeight: FontWeight.w700, - color: isDark ? Colors.white70 : AppColors.lightOnSurface, - ), + return GestureDetector( + onTap: () => context.push('/launch'), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark ? AppColors.darkOutline : AppColors.lightOutline, + width: 0.8, ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber), + const SizedBox(width: 4), + Text( + '$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white70 : AppColors.lightOnSurface, + ), + ), + ], + ), ), ); } diff --git a/lib/features/performance/performance_launch_screen.dart b/lib/features/performance/performance_launch_screen.dart new file mode 100644 index 0000000..460b664 --- /dev/null +++ b/lib/features/performance/performance_launch_screen.dart @@ -0,0 +1,1446 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/responsive/responsive_layout.dart'; +import '../../core/responsive/viewport_size.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_typography.dart'; +import '../../core/theme/theme_extensions.dart'; +import 'models/performance_launch_models.dart'; +import 'performance_providers.dart'; + +/// Rider-facing MotorSignal Performance Launch Mode screen. +/// +/// Observes [PerformanceLaunchStateMachine] and presents a deterministic, +/// distraction-free cockpit experience for Auto Launch and Countdown 0-60/0-100 benchmarks. +class PerformanceLaunchScreen extends ConsumerStatefulWidget { + const PerformanceLaunchScreen({super.key}); + + @override + ConsumerState createState() => + _PerformanceLaunchScreenState(); +} + +class _PerformanceLaunchScreenState + extends ConsumerState { + PerformanceLaunchMode _selectedMode = PerformanceLaunchMode.auto; + PerformanceLaunchTarget _selectedTarget = PerformanceLaunchTarget.sixty; + + @override + Widget build(BuildContext context) { + final attemptState = + ref.watch(performanceLaunchProvider.select((s) => s.state)); + final isDark = Theme.of(context).brightness == Brightness.dark; + final colors = Theme.of(context).extension() ?? + (isDark ? MotorSignalThemeColors.dark : MotorSignalThemeColors.light); + + return Scaffold( + backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + key: const ValueKey('btn_cancel_launch'), + icon: Icon( + attemptState == PerformanceLaunchState.launched || + attemptState == PerformanceLaunchState.countdown + ? Icons.close + : Icons.arrow_back, + color: isDark ? Colors.white : Colors.black87, + ), + onPressed: () { + if (attemptState == PerformanceLaunchState.launched || + attemptState == PerformanceLaunchState.countdown || + attemptState == PerformanceLaunchState.ready) { + ref.read(performanceLaunchProvider.notifier).cancel(); + } + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + 'PERFORMANCE LAUNCH', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: isDark ? Colors.white : Colors.black87, + ), + ), + centerTitle: true, + ), + body: SafeArea( + child: ResponsiveLayout( + builder: (context, snapshot) { + return _buildContentForState(context, attemptState, colors, isDark, snapshot); + }, + ), + ), + ); + } + + Widget _buildContentForState( + BuildContext context, + PerformanceLaunchState state, + MotorSignalThemeColors colors, + bool isDark, + ResponsiveSnapshot snapshot, + ) { + final isArmed = ref.watch( + performanceLaunchProvider.select((s) => s.armedAtMonotonicMs != null)); + + switch (state) { + case PerformanceLaunchState.idle: + if (!isArmed) { + return _SetupView( + selectedMode: _selectedMode, + selectedTarget: _selectedTarget, + onModeChanged: (m) => setState(() => _selectedMode = m), + onTargetChanged: (t) => setState(() => _selectedTarget = t), + onArm: () { + ref.read(performanceLaunchProvider.notifier).arm( + mode: _selectedMode, + target: _selectedTarget, + ); + }, + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + } + return _WaitingView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + + case PerformanceLaunchState.ready: + return _ReadyView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + + case PerformanceLaunchState.countdown: + return _CountdownView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + + case PerformanceLaunchState.launched: + return _LaunchedView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + + case PerformanceLaunchState.targetReached: + case PerformanceLaunchState.completed: + return _ResultView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + + case PerformanceLaunchState.aborted: + return _AbortedView( + colors: colors, + isDark: isDark, + snapshot: snapshot, + ); + } + } +} + +// ========================================== +// 1. SETUP VIEW +// ========================================== +class _SetupView extends StatelessWidget { + final PerformanceLaunchMode selectedMode; + final PerformanceLaunchTarget selectedTarget; + final ValueChanged onModeChanged; + final ValueChanged onTargetChanged; + final VoidCallback onArm; + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _SetupView({ + required this.selectedMode, + required this.selectedTarget, + required this.onModeChanged, + required this.onTargetChanged, + required this.onArm, + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context) { + final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; + + if (isShortLandscape) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildModeSelector(context), + const SizedBox(height: 12), + _buildTargetSelector(context), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildExplanationCard(context), + const SizedBox(height: 12), + _buildArmButton(context), + ], + ), + ), + ), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildModeSelector(context), + const SizedBox(height: 16), + _buildTargetSelector(context), + const SizedBox(height: 16), + _buildExplanationCard(context), + const SizedBox(height: 24), + _buildArmButton(context), + ], + ), + ); + } + + Widget _buildModeSelector(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'START MODE', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SelectionButton( + key: const ValueKey('launch_mode_auto'), + title: 'Auto Launch', + subtitle: 'Move to Start', + icon: Icons.flash_on, + isSelected: selectedMode == PerformanceLaunchMode.auto, + onTap: () => onModeChanged(PerformanceLaunchMode.auto), + colors: colors, + isDark: isDark, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SelectionButton( + key: const ValueKey('launch_mode_countdown'), + title: 'Countdown', + subtitle: '3-2-1-GO', + icon: Icons.timer, + isSelected: selectedMode == PerformanceLaunchMode.countdown, + onTap: () => onModeChanged(PerformanceLaunchMode.countdown), + colors: colors, + isDark: isDark, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTargetSelector(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'BENCHMARK TARGET', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SelectionButton( + key: const ValueKey('launch_target_60'), + title: '0–60 MPH', + subtitle: 'Standard Benchmark', + icon: Icons.speed, + isSelected: selectedTarget == PerformanceLaunchTarget.sixty, + onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty), + colors: colors, + isDark: isDark, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SelectionButton( + key: const ValueKey('launch_target_100'), + title: '0–100 MPH', + subtitle: 'With 60 MPH Split', + icon: Icons.rocket_launch, + isSelected: selectedTarget == PerformanceLaunchTarget.hundred, + onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred), + colors: colors, + isDark: isDark, + ), + ), + ], + ), + ], + ); + } + + Widget _buildExplanationCard(BuildContext context) { + final text = selectedMode == PerformanceLaunchMode.auto + ? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).' + : 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.'; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant, width: 1.0), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colors.velocityBlue, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 13, + height: 1.35, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ), + ); + } + + Widget _buildArmButton(BuildContext context) { + return SizedBox( + height: 52, + child: ElevatedButton( + key: const ValueKey('btn_arm_launch'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.launchGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 2, + ), + onPressed: onArm, + child: const Text( + 'ARM LAUNCH', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + ), + ), + ), + ); + } +} + +class _SelectionButton extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final bool isSelected; + final VoidCallback onTap; + final MotorSignalThemeColors colors; + final bool isDark; + + const _SelectionButton({ + super.key, + required this.title, + required this.subtitle, + required this.icon, + required this.isSelected, + required this.onTap, + required this.colors, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + final borderColor = isSelected ? colors.launchGreen : colors.outlineVariant; + final bgColor = isSelected + ? colors.launchGreen.withValues(alpha: isDark ? 0.18 : 0.12) + : colors.surfaceContainer; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor, width: isSelected ? 2.0 : 1.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 22, + color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54), + ), + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w800, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ========================================== +// 2. WAITING FOR CONDITIONS VIEW +// ========================================== +class _WaitingView extends ConsumerWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _WaitingView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speed = ref.watch( + performanceLaunchProvider.select((s) => s.currentSpeedMph)); + final confidence = ref.watch( + performanceLaunchProvider.select((s) => s.telemetryConfidence)); + + String statusText; + IconData statusIcon; + Color statusColor; + + if (confidence < 0.60) { + statusText = 'WAITING FOR VALID TELEMETRY'; + statusIcon = Icons.satellite_alt; + statusColor = colors.warningAmber; + } else if (speed >= 1.0) { + statusText = 'COME TO A COMPLETE STOP'; + statusIcon = Icons.pan_tool; + statusColor = colors.raceRed; + } else { + statusText = 'CALIBRATING SENSORS...'; + statusIcon = Icons.hourglass_top; + statusColor = colors.velocityBlue; + } + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(statusIcon, size: 54, color: statusColor), + const SizedBox(height: 20), + Text( + statusText, + key: const ValueKey('waiting_status_text'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + color: statusColor, + ), + ), + const SizedBox(height: 12), + Text( + 'Current speed: ${speed.toStringAsFixed(1)} MPH', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 14, + color: isDark ? Colors.white70 : Colors.black54, + ), + ), + const SizedBox(height: 32), + OutlinedButton( + key: const ValueKey('btn_cancel_waiting'), + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + }, + child: const Text('CANCEL'), + ), + ], + ), + ); + } +} + +// ========================================== +// 3. READY VIEW +// ========================================== +class _ReadyView extends ConsumerWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _ReadyView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode)); + final target = ref.watch(performanceLaunchProvider.select((s) => s.target)); + final isCountdown = mode == PerformanceLaunchMode.countdown; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + decoration: BoxDecoration( + color: colors.launchGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.launchGreen, width: 2.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: colors.launchGreen, size: 28), + const SizedBox(width: 10), + Text( + 'READY', + key: const ValueKey('ready_status_badge'), + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 28, + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + color: colors.launchGreen, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + Text( + target.label, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 22, + fontWeight: FontWeight.w800, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 10), + Text( + isCountdown + ? 'Tap below to initiate countdown' + : 'Accelerate when ready', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.black54, + ), + ), + const SizedBox(height: 40), + if (isCountdown) ...[ + SizedBox( + height: 56, + child: ElevatedButton( + key: const ValueKey('btn_start_countdown'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.velocityBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + elevation: 4, + ), + onPressed: () { + ref.read(performanceLaunchProvider.notifier).startCountdown(); + }, + child: const Text( + 'START COUNTDOWN', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 17, + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + Center( + child: TextButton( + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + }, + child: Text( + 'Cancel & Change Setup', + style: TextStyle( + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + ), + ), + ], + ), + ); + } +} + +// ========================================== +// 4. COUNTDOWN VIEW +// ========================================== +class _CountdownView extends ConsumerStatefulWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _CountdownView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + ConsumerState<_CountdownView> createState() => _CountdownViewState(); +} + +class _CountdownViewState extends ConsumerState<_CountdownView> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + // Animation controller purely prompts repaints to read monotonic clock. It is NOT the timer authority. + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..addListener(() { + if (mounted) setState(() {}); + }); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheduledGoMs = ref.watch( + performanceLaunchProvider.select((s) => s.scheduledGoAtMonotonicMs)); + final clock = ref.read(monotonicClockProvider); + + final nowMs = clock.elapsedMilliseconds; + final diffMs = (scheduledGoMs != null) ? scheduledGoMs - nowMs : 3000; + + String digit; + Color color; + + if (diffMs > 2000) { + digit = '3'; + color = widget.colors.warningAmber; + } else if (diffMs > 1000) { + digit = '2'; + color = widget.colors.warningAmber; + } else if (diffMs > 0) { + digit = '1'; + color = widget.colors.warningAmber; + } else { + digit = 'GO!'; + color = widget.colors.launchGreen; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + digit, + key: const ValueKey('countdown_text'), + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: digit == 'GO!' ? 84 : 110, + fontWeight: FontWeight.w900, + color: color, + height: 1.0, + ), + ), + const SizedBox(height: 16), + Text( + digit == 'GO!' ? 'ACCELERATE NOW' : 'PREPARE TO LAUNCH', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + color: widget.isDark ? Colors.white70 : Colors.black54, + ), + ), + const SizedBox(height: 36), + OutlinedButton( + onPressed: () { + ref.read(performanceLaunchProvider.notifier).cancel(); + }, + child: const Text('ABORT'), + ), + ], + ), + ); + } +} + +// ========================================== +// 5. LAUNCHED / TIMING VIEW +// ========================================== +class _LaunchedView extends StatelessWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _LaunchedView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context) { + final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; + + if (isShortLandscape) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Expanded( + flex: 4, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TargetHeaderLeaf(), + SizedBox(height: 6), + _FalseStartBadgeLeaf(), + Spacer(), + _SplitReadoutLeaf(), + ], + ), + ), + const SizedBox(width: 16), + const Expanded( + flex: 6, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LiveSpeedLeaf(compact: true), + SizedBox(height: 4), + _LiveTimerLeaf(), + ], + ), + ), + ], + ), + ); + } + + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _TargetHeaderLeaf(), + SizedBox(height: 8), + _FalseStartBadgeLeaf(), + Spacer(), + _LiveSpeedLeaf(), + SizedBox(height: 12), + _LiveTimerLeaf(), + SizedBox(height: 16), + _SplitReadoutLeaf(), + Spacer(), + ], + ), + ); + } +} + +// Leaf widgets for isolated 20 Hz rebuilds: +class _TargetHeaderLeaf extends ConsumerWidget { + const _TargetHeaderLeaf(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = ref.watch(performanceLaunchProvider.select((s) => s.target)); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + target.label, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w800, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ); + } +} + +class _FalseStartBadgeLeaf extends ConsumerWidget { + const _FalseStartBadgeLeaf(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isFalseStart = + ref.watch(performanceLaunchProvider.select((s) => s.isFalseStart)); + if (!isFalseStart) return const SizedBox.shrink(); + + return Container( + key: const ValueKey('false_start_badge'), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppColors.warningAmber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningAmber, width: 1.0), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber_rounded, + size: 16, color: AppColors.warningAmber), + SizedBox(width: 6), + Text( + 'FALSE START DETECTED', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.warningAmber, + ), + ), + ], + ), + ); + } +} + +class _LiveSpeedLeaf extends ConsumerWidget { + final bool compact; + const _LiveSpeedLeaf({this.compact = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speed = ref.watch( + performanceLaunchProvider.select((s) => s.currentSpeedMph)); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + speed.toStringAsFixed(0), + key: const ValueKey('speed_readout'), + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 64 : 96, + fontWeight: FontWeight.w900, + height: 1.0, + color: isDark ? Colors.white : Colors.black, + ), + ), + const SizedBox(width: 8), + Text( + 'MPH', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: compact ? 18 : 24, + fontWeight: FontWeight.w800, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + ], + ), + ); + } +} + +class _LiveTimerLeaf extends ConsumerWidget { + const _LiveTimerLeaf(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final elapsedSec = ref.watch( + performanceLaunchProvider.select((s) => s.currentElapsedSeconds ?? 0.0)); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Text( + '${elapsedSec.toStringAsFixed(2)} s', + key: const ValueKey('elapsed_timer_readout'), + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 36, + fontWeight: FontWeight.w800, + color: isDark ? AppColors.launchGreen : AppColors.successGreen, + ), + ); + } +} + +class _SplitReadoutLeaf extends ConsumerWidget { + const _SplitReadoutLeaf(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final splits = + ref.watch(performanceLaunchProvider.select((s) => s.splits)); + if (splits.isEmpty) return const SizedBox.shrink(); + + final split = splits.first; + return Container( + key: const ValueKey('split_60_readout'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: AppColors.velocityBlue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.velocityBlue, width: 1.0), + ), + child: Text( + '60 MPH SPLIT: ${split.elapsedFromMovementSec.toStringAsFixed(2)} s', + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.velocityBlue, + ), + ), + ); + } +} + +// ========================================== +// 6. RESULT VIEW +// ========================================== +class _ResultView extends ConsumerWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _ResultView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final result = + ref.watch(performanceLaunchProvider.select((s) => s.lastResult)); + if (result == null) { + return const Center(child: Text('No result data')); + } + + final isCountdown = result.mode == PerformanceLaunchMode.countdown; + final isFalseStart = result.isFalseStart; + final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; + + return Padding( + padding: const EdgeInsets.all(16), + child: isShortLandscape + ? Row( + children: [ + Expanded( + child: SingleChildScrollView( + child: _buildResultCards(context, result, isCountdown, isFalseStart), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildResultActions(context, ref), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + child: _buildResultCards(context, result, isCountdown, isFalseStart), + ), + ), + const SizedBox(height: 12), + _buildResultActions(context, ref), + ], + ), + ); + } + + Widget _buildResultCards( + BuildContext context, + PerformanceLaunchResult result, + bool isCountdown, + bool isFalseStart, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isFalseStart) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.warningAmber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.warningAmber, width: 1.5), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: colors.warningAmber, size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'FALSE START', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w900, + color: colors.warningAmber, + ), + ), + Text( + 'Movement detected before GO.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + + // Main Acceleration Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isFalseStart ? colors.warningAmber : colors.launchGreen, + width: 1.5, + ), + ), + child: Column( + children: [ + Text( + result.target.label, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + const SizedBox(height: 8), + Text( + '${result.accelerationTimeSec.toStringAsFixed(2)} s', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 48, + fontWeight: FontWeight.w900, + color: isDark ? Colors.white : Colors.black, + ), + ), + Text( + 'ACCELERATION', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: colors.launchGreen, + ), + ), + const SizedBox(height: 6), + Text( + 'Top Speed: ${result.topSpeedReachedMph.toStringAsFixed(1)} MPH', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 12, + color: isDark ? Colors.white60 : Colors.black45, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + + // Split (if present) + if (result.split60TimeSec != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant, width: 1.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '60 MPH Split', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${result.split60TimeSec!.toStringAsFixed(2)} s', + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + + // Countdown Reaction metrics + if (isCountdown && result.reactionTimeSec != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.outlineVariant, width: 1.0), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Launch Reaction', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'GO → detected movement', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11, + color: isDark ? Colors.white54 : Colors.black45, + ), + ), + ], + ), + Text( + '${result.reactionTimeSec! >= 0 ? '+' : ''}${result.reactionTimeSec!.toStringAsFixed(2)} s', + key: const ValueKey('launch_reaction_readout'), + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 16, + fontWeight: FontWeight.w800, + color: result.reactionTimeSec! < 0 + ? colors.raceRed + : colors.launchGreen, + ), + ), + ], + ), + if (result.totalTimeFromGoSec != null) ...[ + const Divider(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Total From GO', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + Text( + '${result.totalTimeFromGoSec!.toStringAsFixed(2)} s', + key: const ValueKey('total_from_go_readout'), + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ], + ], + ), + ), + ], + ], + ); + } + + Widget _buildResultActions(BuildContext context, WidgetRef ref) { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: OutlinedButton( + key: const ValueKey('btn_try_again'), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + }, + child: const Text('TRY AGAIN'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + key: const ValueKey('btn_done'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.launchGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + child: const Text('DONE'), + ), + ), + ), + ], + ); + } +} + +// ========================================== +// 7. ABORTED VIEW +// ========================================== +class _AbortedView extends ConsumerWidget { + final MotorSignalThemeColors colors; + final bool isDark; + final ResponsiveSnapshot snapshot; + + const _AbortedView({ + required this.colors, + required this.isDark, + required this.snapshot, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reason = + ref.watch(performanceLaunchProvider.select((s) => s.abortReason)); + + String title; + String detail; + + switch (reason) { + case PerformanceAbortReason.userCancelled: + title = 'LAUNCH CANCELLED'; + detail = 'The attempt was cancelled.'; + break; + case PerformanceAbortReason.telemetryRejected: + case PerformanceAbortReason.gpsLost: + title = 'GPS SIGNAL LOST'; + detail = 'Telemetry was rejected or degraded during the run.'; + break; + case PerformanceAbortReason.lowConfidence: + title = 'SIGNAL QUALITY TOO LOW'; + detail = 'Confidence dropped below threshold.'; + break; + case PerformanceAbortReason.stallOrStop: + title = 'MOTORCYCLE STOPPED'; + detail = 'Vehicle came to a complete stop before reaching target speed.'; + break; + case PerformanceAbortReason.vehicleMotionBeforeReady: + title = 'MOTION BEFORE READY'; + detail = 'Vehicle moved before sensors were stationary.'; + break; + case PerformanceAbortReason.physicalAnomaly: + title = 'DATA ANOMALY'; + detail = 'Anomalous acceleration jump detected.'; + break; + default: + title = 'ATTEMPT ABORTED'; + detail = 'Attempt terminated before completion.'; + break; + } + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.cancel_outlined, size: 56, color: colors.raceRed), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 20, + fontWeight: FontWeight.w900, + color: colors.raceRed, + ), + ), + const SizedBox(height: 8), + Text( + detail, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 14, + color: isDark ? Colors.white70 : Colors.black54, + ), + ), + const SizedBox(height: 32), + SizedBox( + height: 48, + child: ElevatedButton( + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + }, + child: const Text('TRY AGAIN'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/performance/services/performance_launch_state_machine.dart b/lib/features/performance/services/performance_launch_state_machine.dart index 6e3dbd4..8399d6f 100644 --- a/lib/features/performance/services/performance_launch_state_machine.dart +++ b/lib/features/performance/services/performance_launch_state_machine.dart @@ -69,6 +69,8 @@ class PerformanceLaunchStateMachine extends StateNotifier context.push('/launch'), + ), + ), ], ), ); diff --git a/test/performance_launch_screen_test.dart b/test/performance_launch_screen_test.dart new file mode 100644 index 0000000..8986713 --- /dev/null +++ b/test/performance_launch_screen_test.dart @@ -0,0 +1,637 @@ +import 'dart:async'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample; +import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart'; +import 'package:motorcycle_dash/features/performance/performance_launch_screen.dart'; +import 'package:motorcycle_dash/features/performance/performance_providers.dart'; +import 'package:motorcycle_dash/features/performance/services/monotonic_clock.dart'; +import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late StreamController telemetryController; + late FakeMonotonicClock clock; + late RecordsService recordsService; + late SharedPreferences prefs; + late PerformanceLaunchStateMachine stateMachine; + late ProviderContainer container; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + recordsService = RecordsService(prefs); + db = AppDatabase.forTesting(NativeDatabase.memory()); + telemetryController = StreamController.broadcast(); + clock = FakeMonotonicClock( + initialElapsedMs: 10000, + initialDateTime: DateTime.utc(2026, 9, 5, 12, 0, 0), + ); + stateMachine = PerformanceLaunchStateMachine( + db: db, + telemetryStream: telemetryController.stream, + recordsService: recordsService, + clock: clock, + countdownDurationMs: 3000, + ); + container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(db), + monotonicClockProvider.overrideWithValue(clock), + performanceLaunchProvider.overrideWith((ref) => stateMachine), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await telemetryController.close(); + await db.close(); + }); + + TelemetrySample makeSample({ + required double speed, + int? monotonicMs, + DateTime? timestamp, + int? seq, + ValidationStatus status = ValidationStatus.valid, + double confidence = 1.0, + }) { + final ms = monotonicMs ?? clock.elapsedMilliseconds; + final dt = timestamp ?? clock.now; + return TelemetrySample( + timestamp: dt, + monotonicTimestampMs: ms, + sequenceNumber: seq, + speed: speed, + quality: TelemetryQuality( + status: status, + overallConfidence: confidence, + speedConfidence: confidence, + gpsConfidence: confidence, + ), + ); + } + + Widget createTestWidget({ + ThemeData? theme, + Size size = const Size(393, 852), // Pixel 8a default + double textScaleFactor = 1.0, + }) { + return UncontrolledProviderScope( + container: container, + child: MediaQuery( + data: MediaQueryData( + size: size, + textScaler: TextScaler.linear(textScaleFactor), + ), + child: MaterialApp( + theme: theme ?? AppTheme.darkTheme, + home: const PerformanceLaunchScreen(), + ), + ), + ); + } + + group('PLM-2: Setup Experience Tests', () { + testWidgets('1. Auto/Countdown selector renders and 2. 60/100 selector renders', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget); + expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget); + expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget); + expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget); + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(find.text('Auto Launch'), findsOneWidget); + expect(find.text('Countdown'), findsOneWidget); + expect(find.text('0–60 MPH'), findsOneWidget); + expect(find.text('0–100 MPH'), findsOneWidget); + }); + + testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + // Tap Countdown mode and 0-100 target + await tester.tap(find.byKey(const ValueKey('launch_mode_countdown'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('launch_target_100'))); + await tester.pumpAndSettle(); + + // Tap ARM + await tester.tap(find.byKey(const ValueKey('btn_arm_launch'))); + await tester.pumpAndSettle(); + + // Since vehicle has no telemetry yet, state should NOT be READY + expect(stateMachine.state.mode, PerformanceLaunchMode.countdown); + expect(stateMachine.state.target, PerformanceLaunchTarget.hundred); + expect(stateMachine.state.state, PerformanceLaunchState.idle); + + // Verify truthful waiting state renders (no fake ready) + expect(find.byKey(const ValueKey('ready_status_badge')), findsNothing); + expect(find.byKey(const ValueKey('waiting_status_text')), findsOneWidget); + }); + }); + + group('PLM-2: READY Experience Tests', () { + testWidgets('5. waiting state renders truthfully with moving speed & transitions to READY when stopped', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + // Emit sample moving at 15 MPH + telemetryController.add(makeSample(speed: 15.0)); + await tester.pump(const Duration(milliseconds: 20)); + + // Arm while moving + await tester.tap(find.byKey(const ValueKey('btn_arm_launch'))); + await tester.pumpAndSettle(); + + // Should display COME TO A COMPLETE STOP + expect(find.text('COME TO A COMPLETE STOP'), findsOneWidget); + + // Now bring motorcycle to a stop (< 1.0 MPH) with valid telemetry + telemetryController.add(makeSample(speed: 0.0, confidence: 1.0)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Transitions to READY! + expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget); + expect(find.text('READY'), findsOneWidget); + }); + + testWidgets('6. Auto READY says accelerate when ready and no countdown button', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, confidence: 1.0)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget); + expect(find.text('Accelerate when ready'), findsOneWidget); + expect(find.byKey(const ValueKey('btn_start_countdown')), findsNothing); + }); + + testWidgets('7. Countdown READY exposes legitimate countdown start button', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, confidence: 1.0)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget); + expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget); + expect(find.text('Tap below to initiate countdown'), findsOneWidget); + }); + + testWidgets('8. READY survives widget rebuild', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, confidence: 1.0)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget); + + // Trigger rebuild + await tester.pumpWidget(createTestWidget(size: const Size(800, 360))); // landscape resize + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget); + expect(stateMachine.state.state, PerformanceLaunchState.ready); + }); + }); + + group('PLM-2: Countdown Tests', () { + testWidgets('9. 3/2/1/GO derives from authoritative monotonic state & 10. widget animation is not timing authority', (tester) async { + clock.setElapsedMs(1000); + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Start countdown: scheduledGo = 1000 + 3000 = 4000 + await tester.tap(find.byKey(const ValueKey('btn_start_countdown'))); + await tester.pump(const Duration(milliseconds: 20)); + + // At t=1000ms: diff is 3000ms -> shows '3' + expect(find.text('3'), findsOneWidget); + + // Advance monotonic clock to t=2500ms: diff is 1500ms -> shows '2' + clock.setElapsedMs(2500); + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('2'), findsOneWidget); + + // Advance monotonic clock to t=3500ms: diff is 500ms -> shows '1' + clock.setElapsedMs(3500); + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('1'), findsOneWidget); + + // Advance monotonic clock to t=4100ms (past scheduled GO): shows 'GO!' + clock.setElapsedMs(4100); + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('GO!'), findsOneWidget); + }); + + testWidgets('11. rebuild does not restart countdown and 12. orientation/viewport resize preserves schedule', (tester) async { + clock.setElapsedMs(1000); + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('btn_start_countdown'))); + await tester.pump(const Duration(milliseconds: 20)); + + final scheduledGo = stateMachine.state.scheduledGoAtMonotonicMs; + expect(scheduledGo, 4000); + + // Advance time to t=2500 (showing 2) + clock.setElapsedMs(2500); + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('2'), findsOneWidget); + + // Rotate / resize to landscape + await tester.pumpWidget(createTestWidget(size: const Size(800, 360))); + await tester.pump(const Duration(milliseconds: 40)); + + // Still at t=2500, still shows 2, scheduledGo unchanged! + expect(find.text('2'), findsOneWidget); + expect(stateMachine.state.scheduledGoAtMonotonicMs, scheduledGo); + }); + }); + + group('PLM-2: Launched Timing Experience Tests', () { + testWidgets('13. active timing view requires no interaction, 14. speed renders, 15. timer renders', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Launch! + telemetryController.add(makeSample(speed: 12.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Launched state verified + expect(stateMachine.state.state, PerformanceLaunchState.launched); + expect(find.byKey(const ValueKey('speed_readout')), findsOneWidget); + expect(find.byKey(const ValueKey('elapsed_timer_readout')), findsOneWidget); + + // Update speed and time at 20 Hz + telemetryController.add(makeSample(speed: 45.0, monotonicMs: 2500)); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.text('45'), findsOneWidget); + expect(find.text('1.45 s'), findsOneWidget); + }); + + testWidgets('16. 100 target displays authoritative 60 split when available', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.hundred); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Launch + telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + + // Cross 60 MPH at t=3550ms + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3550)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('split_60_readout')), findsOneWidget); + expect(find.text('60 MPH SPLIT: 2.50 s'), findsOneWidget); + }); + + testWidgets('17. 60 target does not invent intermediate split', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + telemetryController.add(makeSample(speed: 40.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.byKey(const ValueKey('split_60_readout')), findsNothing); + }); + }); + + group('PLM-2: Result Presentation Tests', () { + testWidgets('18. Auto result omits Launch Reaction', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4250)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(stateMachine.state.state, PerformanceLaunchState.completed); + expect(find.text('ACCELERATION'), findsOneWidget); + expect(find.text('3.20 s'), findsOneWidget); + // Launch reaction is NOT shown for Auto + expect(find.byKey(const ValueKey('launch_reaction_readout')), findsNothing); + expect(find.text('Launch Reaction'), findsNothing); + }); + + testWidgets('19. Countdown result displays Launch Reaction and 20. Total From GO', (tester) async { + clock.setElapsedMs(1000); + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Start countdown (scheduledGo = 4000) + await tester.tap(find.byKey(const ValueKey('btn_start_countdown'))); + await tester.pump(const Duration(milliseconds: 20)); + + // Rider moves at t=4250ms (+250ms reaction) + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 4250)); + await tester.pump(const Duration(milliseconds: 20)); + + // Cross 60 at t=7450ms (3.20s acceleration, 3.45s from GO) + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 7450)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(stateMachine.state.state, PerformanceLaunchState.completed); + expect(find.byKey(const ValueKey('launch_reaction_readout')), findsOneWidget); + expect(find.text('+0.25 s'), findsOneWidget); + expect(find.byKey(const ValueKey('total_from_go_readout')), findsOneWidget); + expect(find.text('3.45 s'), findsOneWidget); + expect(find.text('Total From GO'), findsOneWidget); + }); + + testWidgets('21. false start displays negative Launch Reaction, 22. labeled, 23. not PR', (tester) async { + clock.setElapsedMs(1000); + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Start countdown (scheduledGo = 4000) + await tester.tap(find.byKey(const ValueKey('btn_start_countdown'))); + await tester.pump(const Duration(milliseconds: 20)); + + // Rider jumps before GO at t=3750ms (-250ms false start) + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 3750)); + await tester.pump(const Duration(milliseconds: 20)); + + // Reaches 60 at t=6950ms + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 6950)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(stateMachine.state.state, PerformanceLaunchState.completed); + expect(stateMachine.state.lastResult!.isFalseStart, isTrue); + + // Verify false start badge and negative reaction + expect(find.text('FALSE START'), findsOneWidget); + expect(find.text('Movement detected before GO.'), findsOneWidget); + expect(find.byKey(const ValueKey('launch_reaction_readout')), findsOneWidget); + expect(find.text('-0.25 s'), findsOneWidget); + + // Verify records service was not updated + expect(recordsService.state.fastest0To60, double.infinity); + }); + + testWidgets('24. Try Again invokes authoritative reset', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 4000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('btn_try_again')), findsOneWidget); + + // Tap Try Again + await tester.tap(find.byKey(const ValueKey('btn_try_again'))); + await tester.pumpAndSettle(); + + // State machine reset back to idle/setup + expect(stateMachine.state.state, PerformanceLaunchState.idle); + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + }); + }); + + group('PLM-2: Telemetry Unavailable Tests', () { + testWidgets('26. unavailable telemetry does not display fake READY & 27. rejected telemetry represented truthfully', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + // Arm auto launch + await tester.tap(find.byKey(const ValueKey('btn_arm_launch'))); + await tester.pumpAndSettle(); + + // Emit low confidence sample + telemetryController.add(makeSample(speed: 0.0, confidence: 0.2)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + // Does NOT show READY + expect(find.byKey(const ValueKey('ready_status_badge')), findsNothing); + expect(find.text('WAITING FOR VALID TELEMETRY'), findsOneWidget); + }); + }); + + group('PLM-2: Responsive & Theme Tests', () { + testWidgets('28. no RenderFlex overflow at 320dp width', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(320, 600))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('29. Pixel-like portrait layout renders clean', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(412, 915))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('30. short landscape phone layout renders clean', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(800, 360))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('31. standard landscape layout renders clean', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(915, 412))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('32. expanded tablet layout renders clean', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(1200, 800))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('34. text scaling smoke test (1.5x scale)', (tester) async { + await tester.pumpWidget(createTestWidget(textScaleFactor: 1.5)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('35. System/Light/Dark theme smoke test', (tester) async { + await tester.pumpWidget(createTestWidget(theme: AppTheme.lightTheme)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + + await tester.pumpWidget(createTestWidget(theme: AppTheme.darkTheme)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('PLM-2: Lifecycle & Orientation State Preservation Tests', () { + testWidgets('36. resize does not recreate authoritative attempt & 37. rebuild does not duplicate persistence', (tester) async { + await tester.pumpWidget(createTestWidget(size: const Size(412, 915))); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(stateMachine.state.state, PerformanceLaunchState.completed); + + // Resize/rebuild multiple times + await tester.pumpWidget(createTestWidget(size: const Size(800, 360))); + await tester.pumpAndSettle(); + await tester.pumpWidget(createTestWidget(size: const Size(320, 600))); + await tester.pumpAndSettle(); + + // Only ONE row in performanceRuns table + await tester.runAsync(() async { + final runs = await db.select(db.performanceRuns).get(); + expect(runs, hasLength(1)); + }); + }); + + testWidgets('25. Done resets state machine safely', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4000)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_done')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('btn_done'))); + await tester.pumpAndSettle(); + + expect(stateMachine.state.state, PerformanceLaunchState.idle); + }); + + testWidgets('33. IME insets do not break setup screen', (tester) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MediaQuery( + data: const MediaQueryData( + size: Size(393, 852), + viewInsets: EdgeInsets.only(bottom: 350), // IME open + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: const PerformanceLaunchScreen(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('38. concurrent arm calls during active launch do not corrupt attempt', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); + telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000)); + await tester.pump(const Duration(milliseconds: 20)); + telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1050)); + await tester.pump(const Duration(milliseconds: 20)); + + expect(stateMachine.state.state, PerformanceLaunchState.launched); + final initialArmedMs = stateMachine.state.armedAtMonotonicMs; + + // Attempt concurrent re-arm during launched state + stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.hundred); + expect(stateMachine.state.state, PerformanceLaunchState.launched); + expect(stateMachine.state.mode, PerformanceLaunchMode.auto); + expect(stateMachine.state.target, PerformanceLaunchTarget.sixty); + expect(stateMachine.state.armedAtMonotonicMs, initialArmedMs); + }); + }); +}