diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index c2d618b..fc19838 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -82,50 +82,109 @@ class LeanAngleIndicator extends ConsumerWidget { return; } - // 3. Reject tilted/kickstand condition (> 12.0 degrees) - if (currentLean > 12.0) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - key: const ValueKey('lean_calibrate_tilted_warning'), - content: Text('Motorcycle is tilted (${currentLean.toStringAsFixed(0)}°). Hold the bike upright and level to calibrate.'), - backgroundColor: const Color(0xFFEF4444), - ), - ); - return; - } + final isTiltedReading = currentLean > 12.0; + bool confirmedUpright = !isTiltedReading; showDialog( context: context, - builder: (ctx) => AlertDialog( - key: const ValueKey('lean_calibrate_dialog'), - title: const Text('Calibrate Lean Angle (Upright)'), - content: const Text( - 'Ensure your motorcycle is completely upright and centered (off kickstand or on level ground).\n\nThis will calibrate 0° upright roll.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), + builder: (ctx) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + key: const ValueKey('lean_calibrate_dialog'), + title: Text( + isTiltedReading + ? 'Calibrate Lean Angle (Upright Recovery)' + : 'Calibrate Lean Angle (Upright)', ), - FilledButton( - key: const ValueKey('confirm_lean_calibrate_button'), - style: FilledButton.styleFrom(backgroundColor: colors.brandCyan), - onPressed: () async { - Navigator.pop(ctx); - await ref.read(sensorFusionServiceProvider).calibrateUpright(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - key: ValueKey('lean_calibrate_success_snackbar'), - content: Text('Lean angle calibrated upright (0°).'), - backgroundColor: Color(0xFF10B981), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isTiltedReading) ...[ + Container( + key: const ValueKey('lean_calibrate_tilted_warning_container'), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.withValues(alpha: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Gauge reads ${currentLean.toStringAsFixed(0)}° (stale calibration, remount, or kickstand tilt).\n\nTo recover 0° upright, ensure the motorcycle is held vertically level and off the kickstand.', + style: const TextStyle(fontSize: 12, color: Colors.amber), + ), + ), + ], + ), ), - ); - } - }, - child: const Text('Calibrate Upright', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + CheckboxListTile( + key: const ValueKey('confirm_physically_upright_checkbox'), + value: confirmedUpright, + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text( + 'I confirm motorcycle is physically upright', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + onChanged: (val) { + setDialogState(() { + confirmedUpright = val ?? false; + }); + }, + ), + ] else ...[ + const Text( + 'Ensure your motorcycle is completely upright and centered (off kickstand or on level ground).\n\nThis will calibrate 0° upright roll.', + ), + ], + ], + ), ), - ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + key: const ValueKey('confirm_lean_calibrate_button'), + style: FilledButton.styleFrom(backgroundColor: colors.brandCyan), + onPressed: () async { + if (!confirmedUpright) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const ValueKey('lean_calibrate_tilted_warning'), + content: Text('Motorcycle is tilted (${currentLean.toStringAsFixed(0)}°). Hold the bike upright and check the confirmation box.'), + backgroundColor: const Color(0xFFEF4444), + ), + ); + return; + } + Navigator.pop(ctx); + await ref.read(sensorFusionServiceProvider).calibrateUpright(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + key: ValueKey('lean_calibrate_success_snackbar'), + content: Text('Lean angle calibrated upright (0°).'), + backgroundColor: Color(0xFF10B981), + ), + ); + } + }, + child: Text( + isTiltedReading ? 'Recalibrate Upright (0°)' : 'Calibrate Upright', + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + ), + ), + ], + ), ), ); } diff --git a/test/lean_angle_tap_calibrate_test.dart b/test/lean_angle_tap_calibrate_test.dart index 9bfdacc..aa824bb 100644 --- a/test/lean_angle_tap_calibrate_test.dart +++ b/test/lean_angle_tap_calibrate_test.dart @@ -147,13 +147,15 @@ void main() { expect(find.byKey(const ValueKey('confirm_lean_calibrate_button')), findsOneWidget); }); - testWidgets('LeanAngleIndicator tap while tilted on kickstand (> 12.0°) blocks calibration with warning', (tester) async { + testWidgets('LeanAngleIndicator tap while tilted on kickstand (> 12.0°) requires explicit upright confirmation and blocks unconfirmed calibration', (tester) async { final sample = TelemetrySample( timestamp: DateTime.now(), leanAngle: 15.0, // Resting on kickstand speed: 0.0, ); + var calibrated = false; + await tester.pumpWidget( ProviderScope( overrides: [ @@ -165,6 +167,9 @@ void main() { RideSession(state: RideState.idle), ), ), + sensorFusionServiceProvider.overrideWith((ref) => SensorFusionServiceMock(() { + calibrated = true; + })), ], child: const MaterialApp( home: Scaffold( @@ -179,14 +184,81 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap'))); + await tester.pumpAndSettle(); + + // Verify recovery dialog appears with tilted warning container + expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsOneWidget); + expect(find.byKey(const ValueKey('lean_calibrate_tilted_warning_container')), findsOneWidget); + + // Attempt calibration without checking the upright confirmation box + await tester.tap(find.byKey(const ValueKey('confirm_lean_calibrate_button'))); await tester.pump(); - // Verify kickstand/tilted warning snackbar is shown + // Calibration must NOT occur and unconfirmed warning snackbar must be shown + expect(calibrated, isFalse); expect(find.byKey(const ValueKey('lean_calibrate_tilted_warning')), findsOneWidget); expect(find.textContaining('Motorcycle is tilted (15°). Hold the bike upright'), findsOneWidget); + }); - // Calibration dialog must NOT appear - expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing); + testWidgets('Right-pinned lean gauge defect (42.0° right while upright) recovers via stationary upright confirmation', (tester) async { + final sample = TelemetrySample( + timestamp: DateTime.now(), + leanAngle: 42.0, // Right-pinned defect on Pixel 8a while Yamaha R6 is upright + speed: 0.0, + ); + + var calibrated = false; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + telemetryStreamProvider.overrideWith( + (ref) => Stream.value(sample), + ), + rideStateMachineProvider.overrideWith( + (ref) => MockRideStateMachine( + RideSession(state: RideState.idle), + ), + ), + sensorFusionServiceProvider.overrideWith((ref) => SensorFusionServiceMock(() { + calibrated = true; + })), + ], + child: const MaterialApp( + home: Scaffold( + body: Center( + child: LeanAngleIndicator(), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify gauge is showing tilted / right-pinned value + expect(find.text('42°'), findsOneWidget); + + // Tap indicator to initiate recovery + await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap'))); + await tester.pumpAndSettle(); + + // Verify recovery dialog opened + expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsOneWidget); + expect(find.text('Calibrate Lean Angle (Upright Recovery)'), findsOneWidget); + expect(find.byKey(const ValueKey('confirm_physically_upright_checkbox')), findsOneWidget); + + // Check the confirmation box confirming bike is physically upright + await tester.tap(find.byKey(const ValueKey('confirm_physically_upright_checkbox'))); + await tester.pumpAndSettle(); + + // Tap recalibrate button + await tester.tap(find.byKey(const ValueKey('confirm_lean_calibrate_button'))); + await tester.pumpAndSettle(); + + // Verify calibration succeeded + expect(calibrated, isTrue, reason: 'Right-pinned gauge must successfully recalibrate upright'); + expect(find.byKey(const ValueKey('lean_calibrate_success_snackbar')), findsOneWidget); }); testWidgets('LeanAngleIndicator tap while acceleration is unstable (> 0.25G) blocks calibration with warning', (tester) async {