fix: implement stationary lean-angle calibration recovery for right-pinned or remounted sensors

- Permit recovery from stale or right-pinned lean angle calibration when motorcycle is physically upright
This commit is contained in:
Developer 2026-10-10 00:28:32 -04:00
parent ab4b322d85
commit 082b22fe00
2 changed files with 173 additions and 42 deletions

View File

@ -82,26 +82,71 @@ 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(
builder: (ctx) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
key: const ValueKey('lean_calibrate_dialog'),
title: const Text('Calibrate Lean Angle (Upright)'),
content: const Text(
title: Text(
isTiltedReading
? 'Calibrate Lean Angle (Upright Recovery)'
: 'Calibrate Lean Angle (Upright)',
),
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),
),
),
],
),
),
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),
@ -111,6 +156,16 @@ class LeanAngleIndicator extends ConsumerWidget {
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) {
@ -123,10 +178,14 @@ class LeanAngleIndicator extends ConsumerWidget {
);
}
},
child: const Text('Calibrate Upright', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)),
child: Text(
isTiltedReading ? 'Recalibrate Upright (0°)' : 'Calibrate Upright',
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
),
),
],
),
),
);
}

View File

@ -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 {