fix: separate ride actions from truthful recording status claims

This commit is contained in:
Developer 2026-09-10 18:01:58 -04:00
parent ec759ea3c5
commit c227244d5f
5 changed files with 487 additions and 31 deletions

View File

@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/telemetry_sample.dart';
import '../../../models/recording_health_status.dart';
import '../../rides/ride_state_machine.dart';
import '../../records/records_service.dart';
@ -28,7 +29,57 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider);
final isRecording = session.state == RideState.riding;
// RID-3C-2 FROZEN PRINCIPLE: RideState controls ACTIONS; the RID-3C-1
// RecordingHealthContract / RecordingHealthStatus controls CLAIMS.
//
// `hasActiveRide` is the ACTION source only. It decides whether the primary
// control offers Stop or Start, and it must NEVER depend on
// canReportRecording, GPS freshness, IMU freshness, database health or
// watchdog health. A degraded or still-warming-up ride MUST remain stoppable:
// an unhealthy ride may never become impossible to stop.
final hasActiveRide = session.state == RideState.riding;
// The visible recording-status CLAIM comes only from the authoritative health
// status resolved by RID-3C-1. This widget performs NO health calculation of
// its own: recordingActive is already the product of canReportRecording plus
// the frozen evidence thresholds, so "RECORDING" can never be claimed from
// RideState alone, nor from service or location-stack presence.
final String statusLabel;
final Color statusColor;
if (!hasActiveRide) {
// No active ride: nothing is being recorded however healthy the location
// stack looks. recordingComplete also lands here, because completion means
// the ride is no longer active and must not be presented as recording.
statusLabel = 'STANDBY';
statusColor = Colors.white70;
} else {
statusLabel = switch (session.healthStatus) {
// Health model authorizes the healthy active claim.
RecordingHealthStatus.recordingActive => 'RECORDING',
// Ride exists but required evidence has not completed yet. Stop stays
// available; promotion to RECORDING happens without rider interaction.
RecordingHealthStatus.recordingArmed => 'WARMING UP',
RecordingHealthStatus.recordingStarting => 'WARMING UP',
// Still recording, but one or more evidence domains are stale. DEGRADED
// does NOT mean the ride ended, telemetry was discarded, the ride failed,
// or the service stopped. No auto-stop, no navigation, no state clearing.
RecordingHealthStatus.recordingDegraded => 'DEGRADED',
// RID-3B-1 finalization has priority and is never shown as RECORDING.
RecordingHealthStatus.recordingFinalizing => 'FINALIZING',
// Unreachable alongside an active ride; must not present as recording.
RecordingHealthStatus.recordingComplete => 'STANDBY',
// Not currently produced by production code; non-claiming and truthful.
RecordingHealthStatus.recordingFailed => 'DEGRADED',
};
// Existing MotorSignal theme semantics from the checkpointed health model:
// active keeps the current cyan treatment, armed is amber (the same token
// this bar already uses for a 2D GPS fix), degraded is orange (the same
// token already used for GPS WEAK - a warning, not danger/red), and
// finalizing is purple. No new visual system is introduced.
statusColor = session.healthStatus.color;
}
final speed = currentSpeedMph.clamp(0.0, 220.0);
final topSpeed = math.max(session.topSpeedMph, records.topSpeedMph);
final totalG = math.sqrt(accelG * accelG + lateralG * lateralG);
@ -87,14 +138,12 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isRecording
? const Color(0xFF00E5FF).withValues(alpha: 0.15)
color: hasActiveRide
? statusColor.withValues(alpha: 0.15)
: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isRecording
? const Color(0xFF00E5FF)
: Colors.white24,
color: hasActiveRide ? statusColor : Colors.white24,
width: 0.8,
),
),
@ -106,21 +155,22 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isRecording
? const Color(0xFF00E5FF)
: Colors.white54,
color: hasActiveRide ? statusColor : Colors.white54,
),
),
const SizedBox(width: 6),
// RID-3C-2: a single compact primary status indicator. The
// claim is always text, never colour-only, and carries no
// animation, modal, snackbar or debug counter, so the
// dashboard stays glanceable and the rider never has to
// monitor MotorSignal while riding.
Text(
isRecording ? 'RECORDING' : 'STANDBY',
statusLabel,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w700,
color: isRecording
? const Color(0xFF00E5FF)
: Colors.white70,
color: hasActiveRide ? statusColor : Colors.white70,
letterSpacing: 0.5,
),
),
@ -361,21 +411,24 @@ class EvolutionPortraitDashboard extends ConsumerWidget {
ref.read(rideStateMachineProvider.notifier).startRide();
}
},
// RID-3C-2: the ACTION control stays purely RideState-driven and
// is deliberately independent of statusLabel/statusColor, so Stop
// remains available while warming up, degraded, or finalizing.
style: ElevatedButton.styleFrom(
backgroundColor: isRecording ? const Color(0xFF3D0000) : const Color(0xFF00252A),
foregroundColor: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
backgroundColor: hasActiveRide ? const Color(0xFF3D0000) : const Color(0xFF00252A),
foregroundColor: hasActiveRide ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isRecording ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
color: hasActiveRide ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF),
width: 1.5,
),
),
elevation: 6,
shadowColor: isRecording ? const Color(0xFFFF2A2A).withValues(alpha: 0.4) : const Color(0xFF00E5FF).withValues(alpha: 0.4),
shadowColor: hasActiveRide ? const Color(0xFFFF2A2A).withValues(alpha: 0.4) : const Color(0xFF00E5FF).withValues(alpha: 0.4),
),
child: Text(
isRecording ? 'STOP SESSION' : 'START SESSION',
hasActiveRide ? 'STOP SESSION' : 'START SESSION',
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 16,

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../rides/ride_state_machine.dart';
import '../../../models/recording_health_status.dart';
/// Authoritative ride status badge/chip.
///
@ -13,12 +14,25 @@ class RideStatusChip extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(
rideStateMachineProvider.select((s) => s.state),
// RID-3C-2: RideState decides WHICH lifecycle branch applies; the RID-3C-1
// health status decides the CLAIM inside an active ride. This widget performs
// no health calculation of its own - recordingActive is already the product of
// canReportRecording plus the frozen evidence thresholds, so RECORDING can
// never be claimed from RideState or service presence alone.
//
// The select stays narrow (state + healthStatus only), so the chip still does
// NOT rebuild on 20 Hz telemetry ticks. healthStatus changes only on real
// warm-up / degradation / recovery / finalization transitions.
final (state, health) = ref.watch(
rideStateMachineProvider.select((s) => (s.state, s.healthStatus)),
);
final isRecording = state == RideState.riding;
final isActiveRide = state == RideState.riding;
final isPaused = state == RideState.paused;
// The glow/emphasis treatment belongs to a genuine healthy recording claim
// only, never to a warm-up or degraded ride.
final claimsRecording =
isActiveRide && health == RecordingHealthStatus.recordingActive;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
@ -26,9 +40,38 @@ class RideStatusChip extends ConsumerWidget {
final Color badgeColor;
final String label;
if (isRecording) {
if (isActiveRide) {
switch (health) {
case RecordingHealthStatus.recordingActive:
// Existing healthy/active treatment for this chip, preserved.
badgeColor = AppColors.raceRed;
label = 'RECORDING';
break;
case RecordingHealthStatus.recordingArmed:
case RecordingHealthStatus.recordingStarting:
// Ride exists but required evidence has not completed yet. Neutral /
// informational; the Stop action remains available elsewhere.
badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'WARMING UP';
break;
case RecordingHealthStatus.recordingFinalizing:
// RID-3B-1 finalization has priority and is never shown as RECORDING.
badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'FINALIZING';
break;
case RecordingHealthStatus.recordingDegraded:
case RecordingHealthStatus.recordingFailed:
// Warning, NOT danger: DEGRADED means the ride is still active and still
// stoppable - not ended, discarded, failed or service-stopped.
badgeColor = AppColors.warningAmber;
label = 'DEGRADED';
break;
case RecordingHealthStatus.recordingComplete:
// Unreachable alongside an active ride; must not present as recording.
badgeColor = theme.colorScheme.onSurfaceVariant;
label = 'STANDBY';
break;
}
} else if (isPaused) {
badgeColor = AppColors.warningAmber;
label = 'PAUSED';
@ -43,10 +86,10 @@ class RideStatusChip extends ConsumerWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: isRecording ? 0.18 : 0.10),
color: badgeColor.withValues(alpha: claimsRecording ? 0.18 : 0.10),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: badgeColor.withValues(alpha: isRecording ? 0.8 : 0.35),
color: badgeColor.withValues(alpha: claimsRecording ? 0.8 : 0.35),
width: 1.0,
),
),
@ -59,7 +102,7 @@ class RideStatusChip extends ConsumerWidget {
decoration: BoxDecoration(
shape: BoxShape.circle,
color: badgeColor,
boxShadow: isRecording
boxShadow: claimsRecording
? [
BoxShadow(
color: badgeColor.withValues(alpha: 0.6),

View File

@ -276,7 +276,13 @@ void main() {
await tester.pump();
expect(find.text('Stop Session'), findsOneWidget);
expect(find.text('RECORDING'), findsOneWidget);
// RID-3C-2: the status chip is a health CLAIM, not a RideState claim. A ride
// started here has no GPS/IMU/DB evidence yet, so the truthful label is
// WARMING UP; RECORDING requires healthStatus == recordingActive. This test's
// purpose is that the session SURVIVES the resize, so the same claim must
// appear both before and after rotation.
expect(find.text('WARMING UP'), findsOneWidget);
expect(find.text('RECORDING'), findsNothing);
// Rotate to landscape
configureViewport(tester, 800, 360);
@ -284,7 +290,8 @@ void main() {
// Verify ride did NOT stop or reset
expect(find.text('Stop Session'), findsOneWidget);
expect(find.text('RECORDING'), findsOneWidget);
expect(find.text('WARMING UP'), findsOneWidget);
expect(find.text('RECORDING'), findsNothing);
});
testWidgets('Alert region displays alert banner and handles dismissal', (

View File

@ -65,8 +65,14 @@ void main() {
expect(find.text('HEADSET'), findsNothing);
expect(find.text('92%'), findsNothing);
// Recording status badge
expect(find.text('RECORDING'), findsOneWidget);
// RID-3C-2: the visible badge is a health CLAIM, not a RideState claim. This
// fixture builds RideSession(state: riding) with the DEFAULT
// healthStatus: recordingArmed and no health evidence, so the truthful label
// is WARMING UP. RideState.riding alone is never sufficient for RECORDING.
// The Stop action remains available throughout - see U2/U9 in
// test/rid3c2_truthful_recording_ui_test.dart.
expect(find.text('WARMING UP'), findsOneWidget);
expect(find.text('RECORDING'), findsNothing);
});
testWidgets('displays GPS 2D FIX when accuracy is 25m', (tester) async {

View File

@ -0,0 +1,347 @@
// RID-3C-2 permanent regression suite: truthful UI recording status.
//
// Locks the frozen separation of concerns:
// RideState -> ACTIONS (Start / Stop availability)
// RecordingHealthStatus / contract -> CLAIMS (the visible status label)
//
// A degraded or warming-up ride must ALWAYS remain stoppable, and "RECORDING"
// must never be claimed from RideState or service presence alone.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/models/recording_health_status.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/evolution_portrait_dashboard.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
/// A contract with every evidence domain satisfied, so `canReportRecording` is
/// genuinely true rather than assumed.
const _healthyContract = RecordingHealthContract(
isForegroundServiceAlive: true,
isTelemetryPipelineActive: true,
isAtLeastOneSensorActive: true,
isDatabasePersistenceOperational: true,
isValidSessionId: true,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
Future<void> pumpDashboard(
WidgetTester tester, {
required RideSession session,
TelemetrySample? telemetry,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: MaterialApp(
home: Scaffold(
// No scroll wrapper: the dashboard's root Column uses flex children and
// therefore requires bounded height, exactly as the existing
// evolution_dashboard_truthful_status_test.dart pumps it.
body: EvolutionPortraitDashboard(
currentSpeedMph: 45.0,
heading: 180.0,
leanAngle: 12.0,
accelG: 0.2,
lateralG: 0.1,
session: session,
telemetry: telemetry,
),
),
),
),
);
await tester.pump();
}
/// Asserts the single primary status indicator reads exactly [label].
void expectStatusLabel(String label) {
expect(find.text(label), findsOneWidget,
reason: 'primary status indicator must read $label');
}
/// Asserts no healthy recording claim is visible anywhere.
void expectNoRecordingClaim() {
expect(find.text('RECORDING'), findsNothing,
reason: 'RECORDING is a health claim and must not be rendered');
}
/// Asserts the Stop ACTION is present and actionable. Stop availability is
/// RideState-driven and must be independent of health.
void expectStopAvailable(WidgetTester tester) {
final finder = find.widgetWithText(ElevatedButton, 'STOP SESSION');
expect(finder, findsOneWidget, reason: 'Stop action must remain available');
expect(tester.widget<ElevatedButton>(finder).onPressed, isNotNull,
reason: 'Stop must be actionable, never disabled by health');
}
/// Asserts the Start ACTION is offered when no ride is active.
void expectStartAvailable(WidgetTester tester) {
expect(find.widgetWithText(ElevatedButton, 'START SESSION'), findsOneWidget);
expect(find.text('STOP SESSION'), findsNothing);
}
group('RID-3C-2 truthful recording status UI', () {
testWidgets('U1 idle shows STANDBY and offers Start', (tester) async {
await pumpDashboard(tester, session: RideSession());
expectStatusLabel('STANDBY');
expectNoRecordingClaim();
expectStartAvailable(tester);
});
testWidgets('U2 warm-up shows WARMING UP and Stop stays available',
(tester) async {
// RideState is already riding but RID-3C-1 health is still armed: the ride
// exists, the evidence has not completed. This is the exact state produced
// by startRide() before the first GPS/IMU/DB evidence arrives.
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingArmed,
startTime: DateTime(2026, 9, 10, 12),
),
);
expectStatusLabel('WARMING UP');
expectNoRecordingClaim();
expectStopAvailable(tester);
});
testWidgets('U3 healthy evidence shows RECORDING', (tester) async {
final session = RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
health: _healthyContract,
);
// Guard the premise: the label is earned by the frozen contract, not by
// RideState.
expect(session.health.canReportRecording, isTrue);
await pumpDashboard(tester, session: session);
expectStatusLabel('RECORDING');
expectStopAvailable(tester);
});
testWidgets('U4 degraded shows DEGRADED and Stop stays available',
(tester) async {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingDegraded,
),
);
expectStatusLabel('DEGRADED');
expectStopAvailable(tester);
// DEGRADED does not mean stopped, ended, discarded or failed.
expect(find.text('STOP SESSION'), findsOneWidget);
});
testWidgets('U5 degraded never renders a healthy RECORDING claim',
(tester) async {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingDegraded,
),
);
expectNoRecordingClaim();
for (final claim in ['RECORDING', 'HEALTHY RECORDING', 'ACTIVE RIDE']) {
expect(find.textContaining(claim), findsNothing,
reason: 'no healthy recording claim may be visible while degraded');
}
});
testWidgets('U6 recovery renders DEGRADED then RECORDING for the same ride',
(tester) async {
const rideId = 7;
await pumpDashboard(
tester,
session: RideSession(
dbId: rideId,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingDegraded,
),
);
expectStatusLabel('DEGRADED');
// Same ride identity recovers purely from authoritative state: no new ride,
// no rider interaction, no GPS/service restart, no dbId change.
await pumpDashboard(
tester,
session: RideSession(
dbId: rideId,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingActive,
health: _healthyContract,
),
);
expectStatusLabel('RECORDING');
expect(find.text('DEGRADED'), findsNothing);
expectStopAvailable(tester);
});
testWidgets('U7 finalizing shows FINALIZING, never RECORDING',
(tester) async {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingFinalizing,
),
);
expectStatusLabel('FINALIZING');
expectNoRecordingClaim();
// RID-3B-1 finalization keeps the ride active, so Stop stays available and
// health updates may not override the finalizing claim.
expectStopAvailable(tester);
});
testWidgets('U8 a completed ride leaves no lingering RECORDING claim',
(tester) async {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.finished,
healthStatus: RecordingHealthStatus.recordingComplete,
),
);
expectStatusLabel('STANDBY');
expectNoRecordingClaim();
expectStartAvailable(tester);
});
testWidgets(
'U9 ACTION/CLAIM separation: Stop survives warm-up AND degraded',
(tester) async {
// MANDATORY: an unhealthy or still-warming-up ride must never become
// impossible to stop. Actions come from RideState; claims come from health.
for (final status in [
RecordingHealthStatus.recordingArmed,
RecordingHealthStatus.recordingDegraded,
RecordingHealthStatus.recordingFinalizing,
]) {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: status,
),
);
expectStopAvailable(tester);
expectNoRecordingClaim();
expect(find.text('START SESSION'), findsNothing,
reason: 'an active ride must never offer Start for status $status');
}
});
testWidgets('U10 service/location presence alone is never RECORDING',
(tester) async {
// A perfect GPS fix is flowing, so the location stack is demonstrably
// healthy and the GPS indicator reports a 3D fix - yet with no active ride
// the status claim must remain STANDBY.
await pumpDashboard(
tester,
session: RideSession(),
telemetry: TelemetrySample(
timestamp: DateTime(2026, 9, 10, 12),
gpsAccuracy: 4.0,
gpsTimestamp: DateTime(2026, 9, 10, 12),
latitude: 34.0,
longitude: -85.0,
speed: 45.0,
heading: 90.0,
),
);
expect(find.text('GPS 3D FIX'), findsOneWidget,
reason: 'premise: the location stack really is healthy');
expectStatusLabel('STANDBY');
expectNoRecordingClaim();
});
testWidgets('U11 every status exposes text, never colour alone',
(tester) async {
final cases = <RecordingHealthStatus, String>{
RecordingHealthStatus.recordingArmed: 'WARMING UP',
RecordingHealthStatus.recordingActive: 'RECORDING',
RecordingHealthStatus.recordingDegraded: 'DEGRADED',
RecordingHealthStatus.recordingFinalizing: 'FINALIZING',
};
for (final entry in cases.entries) {
await pumpDashboard(
tester,
session: RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: entry.key,
health: entry.key == RecordingHealthStatus.recordingActive
? _healthyContract
: const RecordingHealthContract(),
),
);
final finder = find.text(entry.value);
expect(finder, findsOneWidget,
reason: '${entry.key} must render a textual label');
expect(tester.widget<Text>(finder).data, entry.value,
reason: 'status must not be communicated by colour alone');
}
await pumpDashboard(tester, session: RideSession());
expect(find.text('STANDBY'), findsOneWidget);
});
testWidgets('U12 ordinary degradation demands no rider attention',
(tester) async {
final degraded = RideSession(
dbId: 7,
state: RideState.riding,
healthStatus: RecordingHealthStatus.recordingDegraded,
);
await pumpDashboard(tester, session: degraded);
expectStatusLabel('DEGRADED');
void expectNoAttentionSurface(String phase) {
for (final type in [Dialog, AlertDialog, SimpleDialog, SnackBar]) {
expect(find.byType(type), findsNothing,
reason: '$phase: no modal, dialog or snackbar for ordinary health '
'degradation');
}
// A ModalBarrier exists in every MaterialApp route overlay, so its mere
// presence is not an attention surface. What matters is that it never
// blocks input or awaits acknowledgement, which would make the rider
// interact with MotorSignal while riding.
for (final barrier
in tester.widgetList<ModalBarrier>(find.byType(ModalBarrier))) {
expect(barrier.onDismiss, isNull,
reason: '$phase: no blocking modal barrier');
expect(barrier.dismissible, isFalse,
reason: '$phase: no acknowledgement-gated overlay');
}
}
expectNoAttentionSurface('immediately');
// Settle additional time: nothing repeating, flashing or acknowledgement-
// gated may appear, so the rider never has to monitor MotorSignal.
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
expectNoAttentionSurface('after settling');
expectStatusLabel('DEGRADED');
expectStopAvailable(tester);
});
});
}