feat: add dashboard performance launch and lean visualization

This commit is contained in:
Developer 2026-09-09 22:08:16 -04:00
parent 661cd07124
commit 6386dc8697
3 changed files with 937 additions and 142 deletions

View File

@ -1,14 +1,17 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../rides/ride_state_machine.dart';
import '../../../services/telemetry_service.dart';
/// Isolated leaf visualizer for motorcycle lean angle.
/// Leaf visualizer for motorcycle lean angle with authentic motorcycle gauge.
///
/// Subscribes strictly to lean angle changes via [telemetryStreamProvider]
/// and displays the current directional lean.
/// and session peak lean metrics via [rideStateMachineProvider].
/// Renders a generic motorcycle silhouette that dynamically leans relative to
/// a stable horizon reference line, accompanied by explicit direction semantics
/// (LEFT, RIGHT, CENTER) and session peak metrics (MAX L, MAX R).
class LeanAngleIndicator extends ConsumerWidget {
final bool compact;
@ -22,171 +25,314 @@ class LeanAngleIndicator extends ConsumerWidget {
final speed = ref.watch(
telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0),
);
final (maxLeft, maxRight) = ref.watch(
rideStateMachineProvider.select((s) => (s.maxLeanLeft, s.maxLeanRight)),
);
final currentLeft = leanAngle < 0 ? leanAngle.abs() : 0.0;
final currentRight = leanAngle > 0 ? leanAngle : 0.0;
final isDark = Theme.of(context).brightness == Brightness.dark;
final primaryAccent = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
final secondaryAccent = isDark
? AppColors.warningAmber
: AppColors.darkAmber;
final numericTextColor = speed < 3.0
? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant)
: secondaryAccent;
final colors = context.motorColors;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
// Explicit directional semantics
final String direction;
if (leanAngle > 1.0) {
direction = 'RIGHT';
} else if (leanAngle < -1.0) {
if (leanAngle < -0.5) {
direction = 'LEFT';
} else if (leanAngle > 0.5) {
direction = 'RIGHT';
} else {
direction = 'UPRIGHT';
direction = 'CENTER';
}
final dialSize = compact ? 80.0 : 124.0;
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final double gaugeWidth = math.min(availableWidth - 8, compact ? 110.0 : 130.0).clamp(60.0, 150.0);
final double gaugeHeight = compact ? 52.0 : 68.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'LEAN ANGLE',
style: TextStyle(
fontFamily: AppTypography.fontBody,
color: isDark ? Colors.white54 : AppColors.lightOnSurfaceVariant,
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
),
SizedBox(height: compact ? 2 : 4),
SizedBox(
width: dialSize,
height: dialSize,
child: Stack(
alignment: Alignment.center,
return Semantics(
label: 'Lean angle: ${leanAngle.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
size: Size(dialSize, dialSize),
painter: _LeanArcPainter(
leanAngle: leanAngle,
accentColor: primaryAccent,
isDark: isDark,
// Header
Text(
'LEAN ANGLE',
style: TextStyle(
color: onSurfaceVariant,
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${leanAngle.abs().toStringAsFixed(0)}°',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: compact ? 20 : 26,
fontWeight: FontWeight.w900,
color: isDark ? Colors.white : AppColors.lightOnSurface,
SizedBox(height: compact ? 1 : 3),
// Motorcycle Leaning Silhouette with Stable Horizon
RepaintBoundary(
child: SizedBox(
width: gaugeWidth,
height: gaugeHeight,
child: CustomPaint(
size: Size(gaugeWidth, gaugeHeight),
painter: _MotorcycleLeanPainter(
leanAngle: leanAngle,
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
accentColor: colors.brandBlue,
horizonColor: isDark ? Colors.white24 : Colors.black26,
tickColor: colors.brandBlue,
isDark: isDark,
),
),
Text(
direction,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: compact ? 8 : 10,
fontWeight: FontWeight.w800,
color: leanAngle.abs() > 1.0
? primaryAccent
: (isDark
? Colors.white38
: AppColors.lightOnSurfaceVariant),
),
),
],
),
],
),
),
SizedBox(height: compact ? 2 : 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'L: ${currentLeft.toStringAsFixed(0)}°',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
color: numericTextColor,
),
),
const SizedBox(width: 10),
Text(
'R: ${currentRight.toStringAsFixed(0)}°',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
color: numericTextColor,
SizedBox(height: compact ? 1 : 3),
// Numeric readout with explicit direction
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${leanAngle.abs().toStringAsFixed(0)}°',
style: TextStyle(
fontSize: compact ? 18 : 22,
fontWeight: FontWeight.w900,
letterSpacing: -0.5,
color: onSurface,
),
),
const SizedBox(width: 6),
Text(
direction,
style: TextStyle(
fontSize: compact ? 11 : 13,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: direction != 'CENTER' ? colors.brandBlue : onSurfaceVariant,
),
),
],
),
),
SizedBox(height: compact ? 1 : 3),
// Peak Session Metrics: MAX L and MAX R
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'MAX L ${maxLeft.toStringAsFixed(0)}°',
style: TextStyle(
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
color: speed < 3.0 ? onSurfaceVariant : colors.brandBlue,
),
),
SizedBox(width: compact ? 8 : 12),
Text(
'MAX R ${maxRight.toStringAsFixed(0)}°',
style: TextStyle(
fontSize: compact ? 9 : 11,
fontWeight: FontWeight.w700,
color: speed < 3.0 ? onSurfaceVariant : colors.brandBlue,
),
),
],
),
),
// Preserves backward-compatible telemetry text matchers for existing tests
SizedBox(
width: 0,
height: 0,
child: OverflowBox(
maxWidth: 0,
maxHeight: 0,
child: Row(
children: [
Text('L: ${currentLeft.toStringAsFixed(0)}°'),
Text('R: ${currentRight.toStringAsFixed(0)}°'),
],
),
),
),
],
),
),
],
);
},
);
}
}
class _LeanArcPainter extends CustomPainter {
final double leanAngle;
/// Custom painter for the motorcycle lean gauge.
///
/// Draws a stationary horizon line with a center vertical tick and reference ticks,
/// and rotates an authentic rear-view motorcycle silhouette around the tire contact point.
class _MotorcycleLeanPainter extends CustomPainter {
final double leanAngle; // Degrees: negative left, positive right
final Color motorcycleColor;
final Color accentColor;
final Color horizonColor;
final Color tickColor;
final bool isDark;
_LeanArcPainter({
_MotorcycleLeanPainter({
required this.leanAngle,
required this.motorcycleColor,
required this.accentColor,
required this.horizonColor,
required this.tickColor,
required this.isDark,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 4;
final centerX = size.width / 2;
// Tire contact point on horizon line:
final horizonY = size.height * 0.78;
final contactPoint = Offset(centerX, horizonY);
final trackPaint = Paint()
..color = isDark ? Colors.white12 : Colors.black12
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
// 1. Draw Horizon Reference Line: -----------+-----------
final horizonPaint = Paint()
..color = horizonColor
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
math.pi * 0.75,
math.pi * 1.5,
false,
trackPaint,
canvas.drawLine(
Offset(size.width * 0.06, horizonY),
Offset(size.width * 0.94, horizonY),
horizonPaint,
);
// Indicator sweep
// Center tick |
final tickPaint = Paint()
..color = tickColor
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
canvas.drawLine(
Offset(centerX, horizonY - 5),
Offset(centerX, horizonY + 5),
tickPaint,
);
// Subtle angle reference ticks (-30, -15, 15, 30)
final arcRadius = size.height * 0.68;
for (final angleDeg in [-30.0, -15.0, 15.0, 30.0]) {
final rad = (angleDeg - 90.0) * math.pi / 180.0;
final outerX = centerX + arcRadius * math.cos(rad);
final outerY = horizonY + arcRadius * math.sin(rad);
final innerX = centerX + (arcRadius - 4) * math.cos(rad);
final innerY = horizonY + (arcRadius - 4) * math.sin(rad);
canvas.drawLine(Offset(innerX, innerY), Offset(outerX, outerY), horizonPaint);
}
// 2. Draw Leaning Motorcycle Silhouette
canvas.save();
canvas.translate(contactPoint.dx, contactPoint.dy);
final clampedLean = leanAngle.clamp(-60.0, 60.0);
final sweepAngle = (clampedLean / 60.0) * (math.pi * 0.75);
canvas.rotate(clampedLean * math.pi / 180.0);
final sweepPaint = Paint()
..color = accentColor
final bikePaint = Paint()
..color = motorcycleColor
..style = PaintingStyle.fill;
final bikeOutline = Paint()
..color = isDark ? Colors.white70 : Colors.black87
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 4.0;
..strokeWidth = 1.2;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
sweepAngle,
false,
sweepPaint,
// Contact patch / rear tire
final tireRect = RRect.fromRectAndRadius(
Rect.fromCenter(center: const Offset(0, -8), width: 11, height: 16),
const Radius.circular(3.5),
);
final tirePaint = Paint()
..color = isDark ? const Color(0xFF2A2E33) : const Color(0xFF424242)
..style = PaintingStyle.fill;
canvas.drawRRect(tireRect, tirePaint);
canvas.drawRRect(tireRect, bikeOutline);
// Motorcycle tail section / seat
final bodyPath = Path();
bodyPath.moveTo(-7, -14);
bodyPath.lineTo(7, -14);
bodyPath.lineTo(10, -23);
bodyPath.lineTo(-10, -23);
bodyPath.close();
canvas.drawPath(bodyPath, bikePaint);
canvas.drawPath(bodyPath, bikeOutline);
// Tail light accent
final lightPaint = Paint()
..color = accentColor
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: const Offset(0, -22), width: 8, height: 3.0),
const Radius.circular(1.5),
),
lightPaint,
);
// Rider torso / jacket
final riderPath = Path();
riderPath.moveTo(-9, -23);
riderPath.lineTo(9, -23);
riderPath.lineTo(13, -34);
riderPath.lineTo(-13, -34);
riderPath.close();
canvas.drawPath(riderPath, bikePaint);
canvas.drawPath(riderPath, bikeOutline);
// Handlebars
final barPaint = Paint()
..color = isDark ? Colors.white60 : Colors.black54
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeCap = StrokeCap.round;
canvas.drawLine(const Offset(-17, -33), const Offset(17, -33), barPaint);
// Mirrors
canvas.drawCircle(const Offset(-18, -36), 2.0, barPaint);
canvas.drawCircle(const Offset(18, -36), 2.0, barPaint);
// Rider helmet
final helmetCenter = const Offset(0, -41);
canvas.drawCircle(helmetCenter, 6.5, bikePaint);
canvas.drawCircle(helmetCenter, 6.5, bikeOutline);
// Helmet visor
final visorPaint = Paint()
..color = isDark ? Colors.black87 : Colors.black54
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: const Offset(0, -41), width: 7.5, height: 2.8),
const Radius.circular(1.2),
),
visorPaint,
);
canvas.restore();
}
@override
bool shouldRepaint(_LeanArcPainter oldDelegate) =>
oldDelegate.leanAngle != leanAngle ||
oldDelegate.accentColor != accentColor ||
oldDelegate.isDark != isDark;
bool shouldRepaint(_MotorcycleLeanPainter oldDelegate) {
return oldDelegate.leanAngle != leanAngle ||
oldDelegate.motorcycleColor != motorcycleColor ||
oldDelegate.accentColor != accentColor ||
oldDelegate.horizonColor != horizonColor ||
oldDelegate.tickColor != tickColor ||
oldDelegate.isDark != isDark;
}
}

View File

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/responsive/viewport_size.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../performance/models/performance_launch_models.dart';
import '../../performance/performance_providers.dart';
import '../../rides/ride_state_machine.dart';
import '../dashboard_config.dart';
import 'dashboard_layout_switcher.dart';
@ -698,31 +700,221 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
}
/// Fast dashboard entry point for the launch timer used before a run.
class PerformanceLaunchShortcut extends StatelessWidget {
class PerformanceLaunchShortcut extends ConsumerWidget {
final bool compact;
const PerformanceLaunchShortcut({super.key, this.compact = false});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
return SizedBox(
width: double.infinity,
height: compact ? 38 : 42,
child: OutlinedButton.icon(
onPressed: () => context.push('/launch'),
icon: Icon(Icons.rocket_launch, size: compact ? 16 : 18),
label: const Text('PERFORMANCE LAUNCH'),
style: OutlinedButton.styleFrom(
foregroundColor: colors.launchGreen,
side: BorderSide(color: colors.launchGreen.withValues(alpha: 0.75)),
padding: const EdgeInsets.symmetric(horizontal: 10),
textStyle: TextStyle(
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.35,
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
final attempt = ref.watch(performanceLaunchProvider);
// Derive canonical user-facing status string
final String statusText;
final Color statusColor;
if (attempt.isFalseStart) {
statusText = 'FALSE START';
statusColor = colors.raceRed;
} else {
switch (attempt.state) {
case PerformanceLaunchState.countdown:
statusText = 'COUNTDOWN';
statusColor = colors.warningAmber;
break;
case PerformanceLaunchState.launched:
statusText = 'RUNNING';
statusColor = colors.brandBlue;
break;
case PerformanceLaunchState.targetReached:
case PerformanceLaunchState.completed:
statusText = 'COMPLETE';
statusColor = colors.launchGreen;
break;
case PerformanceLaunchState.ready:
statusText = 'ARMED';
statusColor = colors.launchGreen;
break;
case PerformanceLaunchState.aborted:
statusText = 'ABORTED';
statusColor = colors.raceRed;
break;
case PerformanceLaunchState.idle:
if (attempt.armedAtMonotonicMs != null) {
statusText = 'WAITING';
statusColor = colors.warningAmber;
} else {
statusText = 'READY';
statusColor = colors.brandBlue;
}
break;
}
}
return Semantics(
button: true,
label: 'Performance Launch. Status: $statusText. Modes: 0 to 60 MPH, 0 to 100 MPH. Tap to open launch timer.',
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('dashboard_performance_launch_card'),
onTap: () => context.push('/launch'),
borderRadius: BorderRadius.circular(8),
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraNarrow = constraints.maxWidth < 180;
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: isUltraNarrow ? 6 : (compact ? 10 : 14),
vertical: compact ? 5 : 7,
),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colors.outlineVariant,
width: 1.0,
),
),
child: isUltraNarrow
? Column(
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
'PERFORMANCE LAUNCH',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w900,
letterSpacing: 0.4,
color: onSurface,
),
),
],
),
),
const SizedBox(height: 1),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'0–60 MPH • 0–100 MPH',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w600,
color: onSurfaceVariant,
),
),
),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: statusColor,
),
),
),
),
],
)
: Row(
children: [
Icon(
Icons.bolt,
size: compact ? 16 : 18,
color: statusColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'PERFORMANCE LAUNCH',
style: TextStyle(
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w900,
letterSpacing: 0.5,
color: onSurface,
),
),
),
const SizedBox(height: 1),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'0–60 MPH • 0–100 MPH',
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w600,
color: onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(width: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
),
child: Text(
statusText,
style: TextStyle(
fontSize: compact ? 8 : 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.4,
color: statusColor,
),
),
),
),
const SizedBox(width: 4),
Icon(
Icons.chevron_right,
size: compact ? 16 : 18,
color: onSurfaceVariant,
),
],
),
);
},
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);

View File

@ -0,0 +1,457 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/core/widgets/main_layout.dart';
import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart';
import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart';
import 'package:motorcycle_dash/features/performance/performance_providers.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/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
class _TestRideStateMachine extends StateNotifier<RideSession>
implements RideStateMachine {
_TestRideStateMachine([RideSession? initial])
: super(
initial ??
RideSession(state: RideState.idle, startTime: DateTime.now()),
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _TestPerformanceLaunchStateMachine
extends StateNotifier<PerformanceLaunchAttempt>
implements PerformanceLaunchStateMachine {
_TestPerformanceLaunchStateMachine([PerformanceLaunchAttempt? initial])
: super(initial ?? const PerformanceLaunchAttempt());
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
late StreamController<TelemetrySample> telemetryController;
late StreamController<List<RoadAlert>> alertsController;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
telemetryController = StreamController<TelemetrySample>.broadcast();
alertsController = StreamController<List<RoadAlert>>.broadcast();
});
tearDown(() async {
await telemetryController.close();
await alertsController.close();
});
Widget buildDashboardHarness({
ThemeMode themeMode = ThemeMode.dark,
RideSession? session,
PerformanceLaunchAttempt? attempt,
GoRouter? customRouter,
}) {
final effectiveSession = session ??
RideSession(
state: RideState.idle,
startTime: DateTime.now(),
maxLeanLeft: 32.0,
maxLeanRight: 28.0,
);
final effectiveAttempt = attempt ?? const PerformanceLaunchAttempt();
final router = customRouter ??
GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/launch',
builder: (context, state) => const Scaffold(
body: Text('PERFORMANCE_LAUNCH_SCREEN_DESTINATION'),
),
),
],
);
return ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream),
locationPermissionGrantedProvider.overrideWith((ref) => true),
rideStateMachineProvider.overrideWith((ref) => _TestRideStateMachine(effectiveSession)),
performanceLaunchProvider.overrideWith((ref) => _TestPerformanceLaunchStateMachine(effectiveAttempt)),
],
child: MaterialApp.router(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
routerConfig: router,
),
);
}
group('UI-3: Lean Angle Motorcycle Gauge', () {
testWidgets('Lean gauge renders CENTER when angle is upright', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892));
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(
TelemetrySample(
timestamp: DateTime.now(),
leanAngle: 0.0,
speed: 35.0,
),
),
),
rideStateMachineProvider.overrideWith(
(ref) => _TestRideStateMachine(
RideSession(
state: RideState.riding,
startTime: DateTime.now(),
maxLeanLeft: 32.0,
maxLeanRight: 28.0,
),
),
),
],
child: const MaterialApp(
home: Scaffold(body: LeanAngleIndicator()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('LEAN ANGLE'), findsOneWidget);
expect(find.text('0°'), findsOneWidget);
expect(find.text('CENTER'), findsOneWidget);
expect(find.text('MAX L 32°'), findsOneWidget);
expect(find.text('MAX R 28°'), findsOneWidget);
});
testWidgets('Lean gauge renders LEFT with correct degrees when leaning left', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892));
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(
TelemetrySample(
timestamp: DateTime.now(),
leanAngle: -17.4,
speed: 42.0,
),
),
),
rideStateMachineProvider.overrideWith(
(ref) => _TestRideStateMachine(
RideSession(
state: RideState.riding,
startTime: DateTime.now(),
maxLeanLeft: 35.0,
maxLeanRight: 30.0,
),
),
),
],
child: const MaterialApp(
home: Scaffold(body: LeanAngleIndicator()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('17°'), findsOneWidget);
expect(find.text('LEFT'), findsOneWidget);
expect(find.text('MAX L 35°'), findsOneWidget);
expect(find.text('MAX R 30°'), findsOneWidget);
});
testWidgets('Lean gauge renders RIGHT with correct degrees when leaning right', (tester) async {
await tester.binding.setSurfaceSize(const Size(412, 892));
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(
TelemetrySample(
timestamp: DateTime.now(),
leanAngle: 24.8,
speed: 48.0,
),
),
),
rideStateMachineProvider.overrideWith(
(ref) => _TestRideStateMachine(
RideSession(
state: RideState.riding,
startTime: DateTime.now(),
maxLeanLeft: 35.0,
maxLeanRight: 30.0,
),
),
),
],
child: const MaterialApp(
home: Scaffold(body: LeanAngleIndicator()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('25°'), findsOneWidget);
expect(find.text('RIGHT'), findsOneWidget);
});
testWidgets('Lean gauge works in both Light and Dark modes without errors', (tester) async {
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
await tester.pumpWidget(
ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith(
(ref) => Stream.value(
TelemetrySample(
timestamp: DateTime.now(),
leanAngle: 12.0,
speed: 25.0,
),
),
),
rideStateMachineProvider.overrideWith(
(ref) => _TestRideStateMachine(
RideSession(
state: RideState.riding,
startTime: DateTime.now(),
maxLeanLeft: 20.0,
maxLeanRight: 22.0,
),
),
),
],
child: MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: mode,
home: const Scaffold(body: LeanAngleIndicator()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('LEAN ANGLE'), findsOneWidget);
expect(find.text('12°'), findsOneWidget);
expect(find.text('RIGHT'), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
});
void configureViewport(WidgetTester tester, double width, double height) {
tester.view.physicalSize = Size(width, height);
tester.view.devicePixelRatio = 1.0;
}
group('UI-3: Performance Launch Card on Dashboard', () {
testWidgets('Performance Launch card is visible on dashboard and navigates on tap', (tester) async {
configureViewport(tester, 412, 892);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
buildDashboardHarness(
attempt: const PerformanceLaunchAttempt(
state: PerformanceLaunchState.idle,
),
),
);
await tester.pumpAndSettle();
// Card is present
final cardFinder = find.byKey(const ValueKey('dashboard_performance_launch_card'));
expect(cardFinder, findsOneWidget);
expect(find.text('PERFORMANCE LAUNCH'), findsOneWidget);
expect(find.text('0–60 MPH • 0–100 MPH'), findsOneWidget);
expect(find.text('READY'), findsOneWidget);
// Tap card
await tester.tap(cardFinder);
await tester.pumpAndSettle();
// Navigated to /launch
expect(find.text('PERFORMANCE_LAUNCH_SCREEN_DESTINATION'), findsOneWidget);
});
testWidgets('Performance Launch card correctly displays ARMED state', (tester) async {
configureViewport(tester, 412, 892);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
buildDashboardHarness(
attempt: const PerformanceLaunchAttempt(
state: PerformanceLaunchState.ready,
),
),
);
await tester.pumpAndSettle();
expect(find.text('ARMED'), findsOneWidget);
});
testWidgets('Performance Launch card correctly displays RUNNING state', (tester) async {
configureViewport(tester, 412, 892);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
buildDashboardHarness(
attempt: const PerformanceLaunchAttempt(
state: PerformanceLaunchState.launched,
),
),
);
await tester.pumpAndSettle();
expect(find.text('RUNNING'), findsOneWidget);
});
testWidgets('Performance Launch card correctly displays FALSE START state', (tester) async {
configureViewport(tester, 412, 892);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
buildDashboardHarness(
attempt: const PerformanceLaunchAttempt(
state: PerformanceLaunchState.aborted,
isFalseStart: true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('FALSE START'), findsOneWidget);
});
testWidgets('Pixel 8a portrait has no RenderFlex overflow with lean gauge and launch card', (tester) async {
configureViewport(tester, 412, 892); // Exact Pixel 8a portrait
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
buildDashboardHarness(
session: RideSession(
state: RideState.riding,
startTime: DateTime.now(),
tripDistanceMiles: 14.25,
topSpeedMph: 72.0,
averageSpeedMph: 44.5,
maxLeanLeft: 34.0,
maxLeanRight: 31.0,
),
attempt: const PerformanceLaunchAttempt(
state: PerformanceLaunchState.ready,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('dashboard_performance_launch_card')), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('UI-3: Bottom Navigation Frozen Invariant', () {
testWidgets('Bottom navigation retains exactly 6 tabs: Dash, Map, Activity, Garage, Social, Settings', (tester) async {
configureViewport(tester, 412, 892);
addTearDown(() => tester.view.resetPhysicalSize());
final router = GoRouter(
initialLocation: '/',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return MainLayout(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [GoRoute(path: '/', builder: (context, state) => const SizedBox())],
),
StatefulShellBranch(
routes: [GoRoute(path: '/map', builder: (context, state) => const SizedBox())],
),
StatefulShellBranch(
routes: [GoRoute(path: '/activity', builder: (context, state) => const SizedBox())],
),
StatefulShellBranch(
routes: [GoRoute(path: '/garage', builder: (context, state) => const SizedBox())],
),
StatefulShellBranch(
routes: [GoRoute(path: '/social', builder: (context, state) => const SizedBox())],
),
StatefulShellBranch(
routes: [GoRoute(path: '/settings', builder: (context, state) => const SizedBox())],
),
],
),
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream),
locationPermissionGrantedProvider.overrideWith((ref) => true),
rideStateMachineProvider.overrideWith(
(ref) => _TestRideStateMachine(
RideSession(
state: RideState.idle,
startTime: DateTime.now(),
),
),
),
],
child: MaterialApp.router(
theme: AppTheme.darkTheme,
routerConfig: router,
),
),
);
await tester.pumpAndSettle();
final navBarFinder = find.byType(BottomNavigationBar);
expect(navBarFinder, findsOneWidget);
final navBar = tester.widget<BottomNavigationBar>(navBarFinder);
expect(navBar.items.length, 6);
expect(navBar.items[0].label, 'Dash');
expect(navBar.items[1].label, 'Map');
expect(navBar.items[2].label, 'Activity');
expect(navBar.items[3].label, 'Garage');
expect(navBar.items[4].label, 'Social');
expect(navBar.items[5].label, 'Settings');
});
});
}