feat: add dashboard performance launch and lean visualization
This commit is contained in:
parent
661cd07124
commit
6386dc8697
|
|
@ -1,14 +1,17 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../rides/ride_state_machine.dart';
|
||||||
import '../../../services/telemetry_service.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]
|
/// 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 {
|
class LeanAngleIndicator extends ConsumerWidget {
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
|
||||||
|
|
@ -22,171 +25,314 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
final speed = ref.watch(
|
final speed = ref.watch(
|
||||||
telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0),
|
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 currentLeft = leanAngle < 0 ? leanAngle.abs() : 0.0;
|
||||||
final currentRight = leanAngle > 0 ? leanAngle : 0.0;
|
final currentRight = leanAngle > 0 ? leanAngle : 0.0;
|
||||||
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final colors = context.motorColors;
|
||||||
final primaryAccent = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
|
final theme = Theme.of(context);
|
||||||
final secondaryAccent = isDark
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
? AppColors.warningAmber
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
: AppColors.darkAmber;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
final numericTextColor = speed < 3.0
|
|
||||||
? (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant)
|
|
||||||
: secondaryAccent;
|
|
||||||
|
|
||||||
|
// Explicit directional semantics
|
||||||
final String direction;
|
final String direction;
|
||||||
if (leanAngle > 1.0) {
|
if (leanAngle < -0.5) {
|
||||||
direction = 'RIGHT';
|
|
||||||
} else if (leanAngle < -1.0) {
|
|
||||||
direction = 'LEFT';
|
direction = 'LEFT';
|
||||||
|
} else if (leanAngle > 0.5) {
|
||||||
|
direction = 'RIGHT';
|
||||||
} else {
|
} 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(
|
return Semantics(
|
||||||
mainAxisSize: MainAxisSize.min,
|
label: 'Lean angle: ${leanAngle.abs().toStringAsFixed(0)} degrees $direction. Max left: ${maxLeft.toStringAsFixed(0)} degrees. Max right: ${maxRight.toStringAsFixed(0)} degrees.',
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'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,
|
|
||||||
children: [
|
children: [
|
||||||
CustomPaint(
|
// Header
|
||||||
size: Size(dialSize, dialSize),
|
Text(
|
||||||
painter: _LeanArcPainter(
|
'LEAN ANGLE',
|
||||||
leanAngle: leanAngle,
|
style: TextStyle(
|
||||||
accentColor: primaryAccent,
|
color: onSurfaceVariant,
|
||||||
isDark: isDark,
|
fontSize: compact ? 9 : 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Column(
|
SizedBox(height: compact ? 1 : 3),
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
// Motorcycle Leaning Silhouette with Stable Horizon
|
||||||
Text(
|
RepaintBoundary(
|
||||||
'${leanAngle.abs().toStringAsFixed(0)}°',
|
child: SizedBox(
|
||||||
style: TextStyle(
|
width: gaugeWidth,
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
height: gaugeHeight,
|
||||||
fontSize: compact ? 20 : 26,
|
child: CustomPaint(
|
||||||
fontWeight: FontWeight.w900,
|
size: Size(gaugeWidth, gaugeHeight),
|
||||||
color: isDark ? Colors.white : AppColors.lightOnSurface,
|
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(
|
SizedBox(height: compact ? 1 : 3),
|
||||||
'R: ${currentRight.toStringAsFixed(0)}°',
|
|
||||||
style: TextStyle(
|
// Numeric readout with explicit direction
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
FittedBox(
|
||||||
fontSize: compact ? 9 : 11,
|
fit: BoxFit.scaleDown,
|
||||||
fontWeight: FontWeight.w700,
|
child: Row(
|
||||||
color: numericTextColor,
|
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 {
|
/// Custom painter for the motorcycle lean gauge.
|
||||||
final double leanAngle;
|
///
|
||||||
|
/// 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 accentColor;
|
||||||
|
final Color horizonColor;
|
||||||
|
final Color tickColor;
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
|
|
||||||
_LeanArcPainter({
|
_MotorcycleLeanPainter({
|
||||||
required this.leanAngle,
|
required this.leanAngle,
|
||||||
|
required this.motorcycleColor,
|
||||||
required this.accentColor,
|
required this.accentColor,
|
||||||
|
required this.horizonColor,
|
||||||
|
required this.tickColor,
|
||||||
required this.isDark,
|
required this.isDark,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final center = Offset(size.width / 2, size.height / 2);
|
final centerX = size.width / 2;
|
||||||
final radius = size.width / 2 - 4;
|
// Tire contact point on horizon line:
|
||||||
|
final horizonY = size.height * 0.78;
|
||||||
|
final contactPoint = Offset(centerX, horizonY);
|
||||||
|
|
||||||
final trackPaint = Paint()
|
// 1. Draw Horizon Reference Line: -----------+-----------
|
||||||
..color = isDark ? Colors.white12 : Colors.black12
|
final horizonPaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..color = horizonColor
|
||||||
..strokeWidth = 3.0;
|
..strokeWidth = 1.5
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
canvas.drawArc(
|
canvas.drawLine(
|
||||||
Rect.fromCircle(center: center, radius: radius),
|
Offset(size.width * 0.06, horizonY),
|
||||||
math.pi * 0.75,
|
Offset(size.width * 0.94, horizonY),
|
||||||
math.pi * 1.5,
|
horizonPaint,
|
||||||
false,
|
|
||||||
trackPaint,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 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()
|
final bikePaint = Paint()
|
||||||
..color = accentColor
|
..color = motorcycleColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final bikeOutline = Paint()
|
||||||
|
..color = isDark ? Colors.white70 : Colors.black87
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeCap = StrokeCap.round
|
..strokeWidth = 1.2;
|
||||||
..strokeWidth = 4.0;
|
|
||||||
|
|
||||||
canvas.drawArc(
|
// Contact patch / rear tire
|
||||||
Rect.fromCircle(center: center, radius: radius),
|
final tireRect = RRect.fromRectAndRadius(
|
||||||
-math.pi / 2,
|
Rect.fromCenter(center: const Offset(0, -8), width: 11, height: 16),
|
||||||
sweepAngle,
|
const Radius.circular(3.5),
|
||||||
false,
|
|
||||||
sweepPaint,
|
|
||||||
);
|
);
|
||||||
|
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
|
@override
|
||||||
bool shouldRepaint(_LeanArcPainter oldDelegate) =>
|
bool shouldRepaint(_MotorcycleLeanPainter oldDelegate) {
|
||||||
oldDelegate.leanAngle != leanAngle ||
|
return oldDelegate.leanAngle != leanAngle ||
|
||||||
oldDelegate.accentColor != accentColor ||
|
oldDelegate.motorcycleColor != motorcycleColor ||
|
||||||
oldDelegate.isDark != isDark;
|
oldDelegate.accentColor != accentColor ||
|
||||||
|
oldDelegate.horizonColor != horizonColor ||
|
||||||
|
oldDelegate.tickColor != tickColor ||
|
||||||
|
oldDelegate.isDark != isDark;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/responsive/viewport_size.dart';
|
import '../../../core/responsive/viewport_size.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/theme_extensions.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 '../../rides/ride_state_machine.dart';
|
||||||
import '../dashboard_config.dart';
|
import '../dashboard_config.dart';
|
||||||
import 'dashboard_layout_switcher.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.
|
/// Fast dashboard entry point for the launch timer used before a run.
|
||||||
class PerformanceLaunchShortcut extends StatelessWidget {
|
class PerformanceLaunchShortcut extends ConsumerWidget {
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
|
||||||
const PerformanceLaunchShortcut({super.key, this.compact = false});
|
const PerformanceLaunchShortcut({super.key, this.compact = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
return SizedBox(
|
final theme = Theme.of(context);
|
||||||
width: double.infinity,
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
height: compact ? 38 : 42,
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () => context.push('/launch'),
|
final attempt = ref.watch(performanceLaunchProvider);
|
||||||
icon: Icon(Icons.rocket_launch, size: compact ? 16 : 18),
|
|
||||||
label: const Text('PERFORMANCE LAUNCH'),
|
// Derive canonical user-facing status string
|
||||||
style: OutlinedButton.styleFrom(
|
final String statusText;
|
||||||
foregroundColor: colors.launchGreen,
|
final Color statusColor;
|
||||||
side: BorderSide(color: colors.launchGreen.withValues(alpha: 0.75)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
if (attempt.isFalseStart) {
|
||||||
textStyle: TextStyle(
|
statusText = 'FALSE START';
|
||||||
fontSize: compact ? 10 : 11,
|
statusColor = colors.raceRed;
|
||||||
fontWeight: FontWeight.w800,
|
} else {
|
||||||
letterSpacing: 0.35,
|
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)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue