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:
parent
ab4b322d85
commit
082b22fe00
|
|
@ -82,26 +82,71 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Reject tilted/kickstand condition (> 12.0 degrees)
|
final isTiltedReading = currentLean > 12.0;
|
||||||
if (currentLean > 12.0) {
|
bool confirmedUpright = !isTiltedReading;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||||
key: const ValueKey('lean_calibrate_dialog'),
|
key: const ValueKey('lean_calibrate_dialog'),
|
||||||
title: const Text('Calibrate Lean Angle (Upright)'),
|
title: Text(
|
||||||
content: const 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.',
|
'Ensure your motorcycle is completely upright and centered (off kickstand or on level ground).\n\nThis will calibrate 0° upright roll.',
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
|
@ -111,6 +156,16 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
key: const ValueKey('confirm_lean_calibrate_button'),
|
key: const ValueKey('confirm_lean_calibrate_button'),
|
||||||
style: FilledButton.styleFrom(backgroundColor: colors.brandCyan),
|
style: FilledButton.styleFrom(backgroundColor: colors.brandCyan),
|
||||||
onPressed: () async {
|
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);
|
Navigator.pop(ctx);
|
||||||
await ref.read(sensorFusionServiceProvider).calibrateUpright();
|
await ref.read(sensorFusionServiceProvider).calibrateUpright();
|
||||||
if (context.mounted) {
|
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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,15 @@ void main() {
|
||||||
expect(find.byKey(const ValueKey('confirm_lean_calibrate_button')), findsOneWidget);
|
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(
|
final sample = TelemetrySample(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
leanAngle: 15.0, // Resting on kickstand
|
leanAngle: 15.0, // Resting on kickstand
|
||||||
speed: 0.0,
|
speed: 0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var calibrated = false;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
@ -165,6 +167,9 @@ void main() {
|
||||||
RideSession(state: RideState.idle),
|
RideSession(state: RideState.idle),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
sensorFusionServiceProvider.overrideWith((ref) => SensorFusionServiceMock(() {
|
||||||
|
calibrated = true;
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(
|
child: const MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
|
|
@ -179,14 +184,81 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('lean_angle_indicator_tap')));
|
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();
|
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.byKey(const ValueKey('lean_calibrate_tilted_warning')), findsOneWidget);
|
||||||
expect(find.textContaining('Motorcycle is tilted (15°). Hold the bike upright'), findsOneWidget);
|
expect(find.textContaining('Motorcycle is tilted (15°). Hold the bike upright'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
// Calibration dialog must NOT appear
|
testWidgets('Right-pinned lean gauge defect (42.0° right while upright) recovers via stationary upright confirmation', (tester) async {
|
||||||
expect(find.byKey(const ValueKey('lean_calibrate_dialog')), findsNothing);
|
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 {
|
testWidgets('LeanAngleIndicator tap while acceleration is unstable (> 0.25G) blocks calibration with warning', (tester) async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue