Fix/landscape dashboard replay batch1 (#85)

* fix(dashboard): resolve landscape clipping and nav rail alignment (#54)

* fix(alerts): constrain road incident report modal in landscape (#55)

* fix(performance): adapt launch setup layout in landscape (#56)

* fix(performance): resolve 68px countdown overflow in landscape (#57)

* fix(history): resolve 12px card overflow in landscape (#59)

* fix(map): remove duplicate replay action in landscape (#60)

* fix(replay): optimize ride replay layout in landscape (#61)

* fix(dashboard): prevent Start Session button text clipping in landscape (#54)

* fix(dashboard): position lean angle title above rider graphic to prevent overlap (#54)

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-17 19:06:30 -04:00 committed by GitHub
parent 1fdca019fb
commit 3f64083bdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1019 additions and 465 deletions

View File

@ -223,6 +223,8 @@ class HybridRidingActionBar extends ConsumerWidget {
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: Text(
finishLabel ?? 'Finish Ride', finishLabel ?? 'Finish Ride',
maxLines: 1,
softWrap: false,
style: const TextStyle( style: const TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: 15,
@ -238,7 +240,6 @@ class HybridRidingActionBar extends ConsumerWidget {
); );
} }
// Ready / Standby state: Single prominent start button // Ready / Standby state: Single prominent start button
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
@ -253,18 +254,24 @@ class HybridRidingActionBar extends ConsumerWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
elevation: 0, elevation: 0,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
), ),
onPressed: () { onPressed: () {
notifier.startRide(); notifier.startRide();
}, },
icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan), icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan),
label: const Text( label: const FittedBox(
'Start Session', fit: BoxFit.scaleDown,
style: TextStyle( child: Text(
fontFamily: AppTypography.fontHeadline, 'Start Session',
fontSize: 17, maxLines: 1,
fontWeight: FontWeight.w700, softWrap: false,
letterSpacing: 0.5, style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
), ),
), ),
), ),

View File

@ -138,45 +138,48 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
minHeight: constraints.maxHeight, minHeight: constraints.maxHeight,
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
child: NavigationRail( child: () {
minWidth: 58, final isShortRail = constraints.maxHeight < 400;
groupAlignment: 0.0, return NavigationRail(
selectedIndex: selectedIndex, minWidth: isShortRail ? 52 : 58,
onDestinationSelected: _onDestinationSelected, groupAlignment: isShortRail ? -1.0 : 0.0,
backgroundColor: colors.surfaceCanvas, selectedIndex: selectedIndex,
indicatorColor: colors.brandCyan.withValues(alpha: 0.14), onDestinationSelected: _onDestinationSelected,
selectedIconTheme: IconThemeData( backgroundColor: colors.surfaceCanvas,
color: colors.brandCyan, indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
size: 21, selectedIconTheme: IconThemeData(
), color: colors.brandCyan,
unselectedIconTheme: IconThemeData( size: isShortRail ? 19 : 21,
color: colors.textMuted, ),
size: 19, unselectedIconTheme: IconThemeData(
), color: colors.textMuted,
selectedLabelTextStyle: TextStyle( size: isShortRail ? 17 : 19,
fontFamily: AppTypography.fontHeadline, ),
fontSize: 10.0, selectedLabelTextStyle: TextStyle(
fontWeight: FontWeight.w700, fontFamily: AppTypography.fontHeadline,
color: colors.brandCyan, fontSize: isShortRail ? 9.0 : 10.0,
), fontWeight: FontWeight.w700,
unselectedLabelTextStyle: TextStyle( color: colors.brandCyan,
fontFamily: AppTypography.fontHeadline, ),
fontSize: 9.5, unselectedLabelTextStyle: TextStyle(
fontWeight: FontWeight.w500, fontFamily: AppTypography.fontHeadline,
color: colors.textMuted, fontSize: isShortRail ? 8.5 : 9.5,
), fontWeight: FontWeight.w500,
labelType: NavigationRailLabelType.all, color: colors.textMuted,
destinations: kAppNavDestinations.map((dest) { ),
return NavigationRailDestination( labelType: NavigationRailLabelType.all,
padding: const EdgeInsets.symmetric(vertical: 4), destinations: kAppNavDestinations.map((dest) {
icon: Icon(dest.icon, key: dest.railKey), return NavigationRailDestination(
selectedIcon: dest.selectedIcon != null padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
? Icon(dest.selectedIcon, key: dest.railKey) icon: Icon(dest.icon, key: dest.railKey),
: null, selectedIcon: dest.selectedIcon != null
label: Text(dest.label), ? Icon(dest.selectedIcon, key: dest.railKey)
); : null,
}).toList(), label: Text(dest.label),
), );
}).toList(),
);
}(),
), ),
), ),
), ),

View File

@ -233,6 +233,7 @@ class LeanAngleIndicator extends ConsumerWidget {
// Header // Header
Text( Text(
'LEAN ANGLE', 'LEAN ANGLE',
key: const ValueKey('lean_angle_title'),
style: TextStyle( style: TextStyle(
color: onSurfaceVariant, color: onSurfaceVariant,
fontSize: compact ? 9 : 11, fontSize: compact ? 9 : 11,
@ -240,22 +241,25 @@ class LeanAngleIndicator extends ConsumerWidget {
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
SizedBox(height: compact ? 1 : 3), SizedBox(height: compact ? 3 : 6),
// Motorcycle Leaning Silhouette with Stable Horizon // Motorcycle Leaning Silhouette with Stable Horizon
RepaintBoundary( RepaintBoundary(
child: SizedBox( child: SizedBox(
key: const ValueKey('lean_angle_rider_graphic'),
width: gaugeWidth, width: gaugeWidth,
height: gaugeHeight, height: gaugeHeight,
child: CustomPaint( child: ClipRect(
size: Size(gaugeWidth, gaugeHeight), child: CustomPaint(
painter: _MotorcycleLeanPainter( size: Size(gaugeWidth, gaugeHeight),
leanAngle: displayLean, painter: _MotorcycleLeanPainter(
motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0), leanAngle: displayLean,
accentColor: colors.brandCyan, motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
horizonColor: isDark ? Colors.white24 : Colors.black26, accentColor: colors.brandCyan,
tickColor: colors.brandCyan, horizonColor: isDark ? Colors.white24 : Colors.black26,
isDark: isDark, tickColor: colors.brandCyan,
isDark: isDark,
),
), ),
), ),
), ),
@ -426,6 +430,10 @@ class _MotorcycleLeanPainter extends CustomPainter {
// 2. Draw Leaning Motorcycle Silhouette // 2. Draw Leaning Motorcycle Silhouette
canvas.save(); canvas.save();
canvas.translate(contactPoint.dx, contactPoint.dy); canvas.translate(contactPoint.dx, contactPoint.dy);
// Scale silhouette to fit comfortably below the top edge of the gauge
final availableTopSpace = horizonY - 4.0;
final double scale = (availableTopSpace / 47.5).clamp(0.5, 0.95);
canvas.scale(scale);
final clampedLean = leanAngle.clamp(-60.0, 60.0); final clampedLean = leanAngle.clamp(-60.0, 60.0);
canvas.rotate(clampedLean * math.pi / 180.0); canvas.rotate(clampedLean * math.pi / 180.0);

View File

@ -326,17 +326,29 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Row( const FittedBox(
mainAxisAlignment: MainAxisAlignment.spaceBetween, fit: BoxFit.scaleDown,
children: [ child: Row(
DashboardLayoutSwitcher(), mainAxisSize: MainAxisSize.min,
GPSStatusIndicator(showLabel: false), children: [
HeadingIndicator(compact: true), DashboardLayoutSwitcher(),
RideStatusChip(), SizedBox(width: 4),
], QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(showLabel: false),
SizedBox(width: 4),
HeadingIndicator(compact: true),
SizedBox(width: 4),
RideStatusChip(),
],
),
), ),
const Center( const Expanded(
child: SpeedReadout(size: SpeedReadoutSize.compact), child: Center(
child: SpeedReadout(size: SpeedReadoutSize.compact),
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@ -386,7 +398,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)), child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
), ),
SizedBox( SizedBox(
width: 140, width: 164,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -463,6 +475,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
children: [ children: [
DashboardLayoutSwitcher(), DashboardLayoutSwitcher(),
SizedBox(width: 4), SizedBox(width: 4),
QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(), GPSStatusIndicator(),
SizedBox(width: 4), SizedBox(width: 4),
HeadingIndicator(compact: true), HeadingIndicator(compact: true),
@ -472,7 +488,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
), ),
), ),
const NavigationManeuverCard(compact: true), const NavigationManeuverCard(compact: true),
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)), const Expanded(
child: Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
),
const RideActionButton(compact: true), const RideActionButton(compact: true),
const SizedBox(height: 4), const SizedBox(height: 4),
const PerformanceLaunchShortcut(compact: true), const PerformanceLaunchShortcut(compact: true),

View File

@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 600, maxCrossAxisExtent: 600,
mainAxisExtent: 175, mainAxisExtent: 196,
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
), ),
itemCount: rides.length, itemCount: rides.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildRideCard(context, rides[index], colors); return _buildRideCard(context, rides[index], colors, isGrid: true);
}, },
); );
} }
@ -154,7 +154,12 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
); );
} }
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) { Widget _buildRideCard(
BuildContext context,
Ride ride,
MotorSignalThemeColors colors, {
bool isGrid = false,
}) {
final bikeName = (ride.motorcycleYear != null && final bikeName = (ride.motorcycleYear != null &&
ride.motorcycleMake != null && ride.motorcycleMake != null &&
ride.motorcycleModel != null) ride.motorcycleModel != null)
@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
return Card( return Card(
elevation: 2, elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 6), margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),

View File

@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
), ),
), ),
// Bottom Center: FIRE OFF REPLAY floating button // Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used)
if (_routePoints.isNotEmpty) if (_routePoints.isNotEmpty && !responsive.isLandscape)
Positioned( Positioned(
bottom: 24, bottom: 24,
left: 20, left: 20,

View File

@ -182,21 +182,23 @@ class _SetupView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; final isLandscape = snapshot.isLandscape;
if (isShortLandscape) { if (isLandscape) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildModeSelector(context), _buildModeSelector(context, isLandscape: true),
const SizedBox(height: 12), const SizedBox(height: 8),
_buildTargetSelector(context), _buildTargetSelector(context, isLandscape: true),
], ],
), ),
), ),
@ -204,12 +206,13 @@ class _SetupView extends StatelessWidget {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildExplanationCard(context), _buildExplanationCard(context, isLandscape: true),
const SizedBox(height: 12), const SizedBox(height: 10),
_buildArmButton(context), _buildArmButton(context, isLandscape: true),
], ],
), ),
), ),
@ -236,7 +239,7 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildModeSelector(BuildContext context) { Widget _buildModeSelector(BuildContext context, {bool isLandscape = false}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -244,13 +247,13 @@ class _SetupView extends StatelessWidget {
'START MODE', 'START MODE',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 1.0, letterSpacing: 1.0,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -263,6 +266,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onModeChanged(PerformanceLaunchMode.auto), onTap: () => onModeChanged(PerformanceLaunchMode.auto),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -276,6 +280,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onModeChanged(PerformanceLaunchMode.countdown), onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
], ],
@ -284,7 +289,7 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildTargetSelector(BuildContext context) { Widget _buildTargetSelector(BuildContext context, {bool isLandscape = false}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -292,13 +297,13 @@ class _SetupView extends StatelessWidget {
'BENCHMARK TARGET', 'BENCHMARK TARGET',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 1.0, letterSpacing: 1.0,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 6 : 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -311,6 +316,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty), onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -324,6 +330,7 @@ class _SetupView extends StatelessWidget {
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred), onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
colors: colors, colors: colors,
isDark: isDark, isDark: isDark,
isLandscape: isLandscape,
), ),
), ),
], ],
@ -332,13 +339,13 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildExplanationCard(BuildContext context) { Widget _buildExplanationCard(BuildContext context, {bool isLandscape = false}) {
final text = selectedMode == PerformanceLaunchMode.auto final text = selectedMode == PerformanceLaunchMode.auto
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).' ? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.'; : 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
return Container( return Container(
padding: const EdgeInsets.all(14), padding: EdgeInsets.all(isLandscape ? 10 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainer, color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -349,17 +356,17 @@ class _SetupView extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.info_outline, Icons.info_outline,
size: 20, size: isLandscape ? 18 : 20,
color: colors.velocityBlue, color: colors.velocityBlue,
), ),
const SizedBox(width: 10), SizedBox(width: isLandscape ? 8 : 10),
Expanded( Expanded(
child: Text( child: Text(
text, text,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 13, fontSize: isLandscape ? 11.5 : 13,
height: 1.35, height: 1.3,
color: isDark ? Colors.white70 : Colors.black87, color: isDark ? Colors.white70 : Colors.black87,
), ),
), ),
@ -369,15 +376,16 @@ class _SetupView extends StatelessWidget {
); );
} }
Widget _buildArmButton(BuildContext context) { Widget _buildArmButton(BuildContext context, {bool isLandscape = false}) {
return SizedBox( return SizedBox(
height: 52, height: isLandscape ? 44 : 52,
width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
key: const ValueKey('btn_arm_launch'), key: const ValueKey('btn_arm_launch'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.launchGreen, backgroundColor: colors.launchGreen,
foregroundColor: Colors.black, foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 2, elevation: 2,
), ),
onPressed: onArm, onPressed: onArm,
@ -385,7 +393,7 @@ class _SetupView extends StatelessWidget {
'Arm Launch', 'Arm Launch',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@ -403,6 +411,7 @@ class _SelectionButton extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final MotorSignalThemeColors colors; final MotorSignalThemeColors colors;
final bool isDark; final bool isDark;
final bool isLandscape;
const _SelectionButton({ const _SelectionButton({
super.key, super.key,
@ -413,6 +422,7 @@ class _SelectionButton extends StatelessWidget {
required this.onTap, required this.onTap,
required this.colors, required this.colors,
required this.isDark, required this.isDark,
this.isLandscape = false,
}); });
@override @override
@ -428,7 +438,10 @@ class _SelectionButton extends StatelessWidget {
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 8 : 12,
vertical: isLandscape ? 8 : 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -439,27 +452,31 @@ class _SelectionButton extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon, icon,
size: 22, size: isLandscape ? 18 : 22,
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54), color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
), ),
const SizedBox(height: 8), SizedBox(height: isLandscape ? 4 : 8),
Text( Text(
title, title,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 15, fontSize: isLandscape ? 13 : 15,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle, subtitle,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontBody, fontFamily: AppTypography.fontBody,
fontSize: 11, fontSize: isLandscape ? 10 : 11,
color: isDark ? Colors.white60 : Colors.black54, color: isDark ? Colors.white60 : Colors.black54,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@ -508,44 +525,48 @@ class _WaitingView extends ConsumerWidget {
statusColor = colors.velocityBlue; statusColor = colors.velocityBlue;
} }
return Padding( final isLandscape = snapshot.isLandscape;
padding: const EdgeInsets.all(24), return Center(
child: Column( child: SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.center, physics: const ClampingScrollPhysics(),
crossAxisAlignment: CrossAxisAlignment.center, padding: EdgeInsets.all(isLandscape ? 16 : 24),
children: [ child: Column(
Icon(statusIcon, size: 54, color: statusColor), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 20), crossAxisAlignment: CrossAxisAlignment.center,
Text( children: [
statusText, Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
key: const ValueKey('waiting_status_text'), SizedBox(height: isLandscape ? 10 : 20),
textAlign: TextAlign.center, Text(
style: TextStyle( statusText,
fontFamily: AppTypography.fontHeadline, key: const ValueKey('waiting_status_text'),
fontSize: 18, textAlign: TextAlign.center,
fontWeight: FontWeight.w800, style: TextStyle(
letterSpacing: 1.0, fontFamily: AppTypography.fontHeadline,
color: statusColor, fontSize: isLandscape ? 15 : 18,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: statusColor,
),
), ),
), SizedBox(height: isLandscape ? 6 : 12),
const SizedBox(height: 12), Text(
Text( 'Current speed: ${speed.toStringAsFixed(1)} MPH',
'Current speed: ${speed.toStringAsFixed(1)} MPH', style: TextStyle(
style: TextStyle( fontFamily: AppTypography.fontTelemetry,
fontFamily: AppTypography.fontTelemetry, fontSize: isLandscape ? 12 : 14,
fontSize: 14, color: isDark ? Colors.white70 : Colors.black54,
color: isDark ? Colors.white70 : Colors.black54, ),
), ),
), SizedBox(height: isLandscape ? 16 : 32),
const SizedBox(height: 32), OutlinedButton(
OutlinedButton( key: const ValueKey('btn_cancel_waiting'),
key: const ValueKey('btn_cancel_waiting'), onPressed: () {
onPressed: () { ref.read(performanceLaunchProvider.notifier).reset();
ref.read(performanceLaunchProvider.notifier).reset(); },
}, child: const Text('Cancel'),
child: const Text('Cancel'), ),
), ],
], ),
), ),
); );
} }
@ -570,108 +591,118 @@ class _ReadyView extends ConsumerWidget {
final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode)); final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
final target = ref.watch(performanceLaunchProvider.select((s) => s.target)); final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
final isCountdown = mode == PerformanceLaunchMode.countdown; final isCountdown = mode == PerformanceLaunchMode.countdown;
final isLandscape = snapshot.isLandscape;
return Padding( return Center(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: SingleChildScrollView(
child: Column( physics: const ClampingScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.symmetric(
crossAxisAlignment: CrossAxisAlignment.stretch, horizontal: isLandscape ? 32 : 20,
children: [ vertical: isLandscape ? 8 : 16,
Center( ),
child: Container( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), mainAxisAlignment: MainAxisAlignment.center,
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.stretch,
color: colors.launchGreen.withValues(alpha: 0.15), children: [
borderRadius: BorderRadius.circular(16), Center(
border: Border.all(color: colors.launchGreen, width: 2.0), child: Container(
padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 16 : 24,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.launchGreen, width: 2.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
const SizedBox(width: 8),
Text(
'READY',
key: const ValueKey('ready_status_badge'),
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: isLandscape ? 20 : 28,
fontWeight: FontWeight.w900,
letterSpacing: 2.0,
color: colors.launchGreen,
),
),
],
),
), ),
child: Row( ),
mainAxisSize: MainAxisSize.min, SizedBox(height: isLandscape ? 8 : 20),
children: [ Text(
Icon(Icons.check_circle, color: colors.launchGreen, size: 28), target.label,
const SizedBox(width: 10), textAlign: TextAlign.center,
Text( style: TextStyle(
'READY', fontFamily: AppTypography.fontHeadline,
key: const ValueKey('ready_status_badge'), fontSize: isLandscape ? 18 : 22,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
),
),
SizedBox(height: isLandscape ? 4 : 10),
Text(
isCountdown
? 'Tap below to initiate countdown'
: 'Accelerate when ready',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: isLandscape ? 13 : 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54,
),
),
SizedBox(height: isLandscape ? 12 : 40),
if (isCountdown) ...[
SizedBox(
height: isLandscape ? 44 : 56,
child: ElevatedButton(
key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.velocityBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
elevation: 4,
),
onPressed: () {
ref.read(performanceLaunchProvider.notifier).startCountdown();
},
child: const Text(
'Start Countdown',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 28, fontSize: 16,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w700,
letterSpacing: 2.0, letterSpacing: 0.5,
color: colors.launchGreen,
), ),
), ),
],
),
),
),
const SizedBox(height: 20),
Text(
target.label,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 10),
Text(
isCountdown
? 'Tap below to initiate countdown'
: 'Accelerate when ready',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 40),
if (isCountdown) ...[
SizedBox(
height: 56,
child: ElevatedButton(
key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.velocityBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
elevation: 4,
), ),
),
SizedBox(height: isLandscape ? 8 : 16),
],
Center(
child: TextButton(
onPressed: () { onPressed: () {
ref.read(performanceLaunchProvider.notifier).startCountdown(); ref.read(performanceLaunchProvider.notifier).reset();
}, },
child: const Text( child: Text(
'Start Countdown', 'Cancel & Change Setup',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, color: isDark ? Colors.white60 : Colors.black54,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
), ),
), ),
), ),
), ),
const SizedBox(height: 16),
], ],
Center( ),
child: TextButton(
onPressed: () {
ref.read(performanceLaunchProvider.notifier).reset();
},
child: Text(
'Cancel & Change Setup',
style: TextStyle(
color: isDark ? Colors.white60 : Colors.black54,
),
),
),
),
],
), ),
); );
} }

View File

@ -7,6 +7,7 @@ import '../../database/database.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../core/theme/theme_extensions.dart'; import '../../core/theme/theme_extensions.dart';
import '../../core/responsive/responsive_layout.dart';
/// Corner performance metrics and apex grading for forensic telemetry replay. /// Corner performance metrics and apex grading for forensic telemetry replay.
class CornerAnalysis { class CornerAnalysis {
@ -474,117 +475,120 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
body: Column( body: ResponsiveLayout(
children: [ builder: (context, snapshot) {
// Map View (with multi-color lean heatmap and telemetry HUD overlay) final isLandscape = snapshot.isLandscape;
Expanded(
flex: 5, final mapWidget = Stack(
child: Stack( children: [
children: [ UnifiedMapView(
UnifiedMapView( center: currentPos,
center: currentPos, zoom: _zoom,
zoom: _zoom, markers: allMarkers,
markers: allMarkers, polylines: polylines,
polylines: polylines, isFollowModeOverride: _isFollowingRider,
isFollowModeOverride: _isFollowingRider, onUserInteraction: () {
onUserInteraction: () { if (_isFollowingRider) {
if (_isFollowingRider) { setState(() => _isFollowingRider = false);
setState(() => _isFollowingRider = false); }
} },
}, ),
), // Map Navigation Controls (Zoom In, Zoom Out, Recenter)
// Map Navigation Controls (Zoom In, Zoom Out, Recenter) Positioned(
Positioned( right: isLandscape ? 10 : 14,
right: 14, bottom: isLandscape ? 10 : 14,
bottom: 14, child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (!_isFollowingRider) ...[
if (!_isFollowingRider) ...[
_ReplayMapButton(
key: const ValueKey('replay_recenter_button'),
icon: Icons.my_location,
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
onTap: () => setState(() => _isFollowingRider = true),
),
const SizedBox(height: 8),
],
_ReplayMapButton( _ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'), key: const ValueKey('replay_recenter_button'),
icon: Icons.add, icon: Icons.my_location,
tooltip: 'Zoom In', tooltip: 'Recenter on Rider',
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), color: colors.cyberCyan,
), backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
const SizedBox(height: 8), isCompact: isLandscape,
_ReplayMapButton( onTap: () => setState(() => _isFollowingRider = true),
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
), ),
SizedBox(height: isLandscape ? 6 : 8),
], ],
_ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
),
SizedBox(height: isLandscape ? 6 : 8),
_ReplayMapButton(
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
isCompact: isLandscape,
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
),
],
),
),
// Telemetry Overlay
Positioned(
top: isLandscape ? 8 : 14,
left: isLandscape ? 8 : 14,
right: isLandscape ? 8 : 14,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
SizedBox(width: isLandscape ? 6 : 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
],
),
), ),
), ),
// Telemetry Overlay ),
Positioned( // Heatmap Legend Overlay
top: 14, Positioned(
left: 14, bottom: isLandscape ? 8 : 10,
right: 14, left: isLandscape ? 8 : 14,
child: Center( right: isLandscape ? 60 : 14,
child: FittedBox( child: Align(
fit: BoxFit.scaleDown, alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white24),
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'), _buildLegendItem(Colors.amberAccent, '≤20°'),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'), _buildLegendItem(Colors.cyanAccent, '20°-35°'),
const SizedBox(width: 8), const SizedBox(width: 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'), _buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
], ],
), ),
), ),
), ),
), ),
// Heatmap Legend Overlay ),
Positioned( ],
bottom: 10, );
left: 14,
right: 14,
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildLegendItem(Colors.amberAccent, '≤20°'),
const SizedBox(width: 8),
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
const SizedBox(width: 8),
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
],
),
),
),
),
),
],
),
),
// Playback Controls Bar final playbackControlsBar = Container(
Container( padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), horizontal: isLandscape ? 12 : 16,
vertical: isLandscape ? 4 : 8,
),
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -596,10 +600,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
child: Text( child: Text(
currentSample.timestamp.toLocal().toString().split('.')[0], currentSample.timestamp.toLocal().toString().split('.')[0],
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: Colors.cyanAccent, color: Colors.cyanAccent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
@ -609,30 +613,42 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
'Sample ${_currentIndex + 1} / ${_telemetry.length}', 'Sample ${_currentIndex + 1} / ${_telemetry.length}',
style: TextStyle( style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 11, fontSize: isLandscape ? 10 : 11,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
], ],
), ),
Slider( SliderTheme(
value: currentSliderValue, data: isLandscape
min: 0, ? SliderTheme.of(context).copyWith(
max: maxSliderValue, trackHeight: 2,
onChanged: _telemetry.length > 1 thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
? (val) { overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
setState(() { )
_currentIndex = val.toInt(); : SliderTheme.of(context),
}); child: Slider(
} value: currentSliderValue,
: null, min: 0,
activeColor: Colors.cyanAccent, max: maxSliderValue,
onChanged: _telemetry.length > 1
? (val) {
setState(() {
_currentIndex = val.toInt();
});
}
: null,
activeColor: Colors.cyanAccent,
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.restart_alt), visualDensity: isLandscape ? VisualDensity.compact : null,
padding: isLandscape ? const EdgeInsets.all(4) : null,
constraints: isLandscape ? const BoxConstraints() : null,
icon: Icon(Icons.restart_alt, size: isLandscape ? 20 : 24),
tooltip: 'Restart', tooltip: 'Restart',
onPressed: () { onPressed: () {
setState(() { setState(() {
@ -640,35 +656,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
}); });
}, },
), ),
const SizedBox(width: 16), SizedBox(width: isLandscape ? 8 : 16),
FloatingActionButton.small( FloatingActionButton.small(
onPressed: _togglePlayback, onPressed: _togglePlayback,
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: isLandscape ? 18 : 20),
), ),
const SizedBox(width: 16), SizedBox(width: isLandscape ? 8 : 16),
TextButton( TextButton(
style: isLandscape
? TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
)
: null,
onPressed: _changeSpeed, onPressed: _changeSpeed,
child: Text( child: Text(
'${_playbackSpeed}x', '${_playbackSpeed}x',
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'), style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: isLandscape ? 12 : 14,
),
), ),
), ),
], ],
), ),
], ],
), ),
), );
// Corner Entry / Apex / Exit Breakdown Table Widget buildCornerTable() {
Expanded( return Container(
flex: _corners.isEmpty ? 3 : 4,
child: Container(
color: theme.colorScheme.surfaceContainerLow, color: theme.colorScheme.surfaceContainerLow,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 14,
vertical: isLandscape ? 6 : 8,
),
color: theme.colorScheme.surfaceContainer, color: theme.colorScheme.surfaceContainer,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -678,7 +705,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
'CORNER APEX BREAKDOWN & GRADING', 'CORNER APEX BREAKDOWN & GRADING',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: isLandscape ? 10 : 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: colors.cyberCyan, color: colors.cyberCyan,
@ -690,7 +717,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
_corners.isEmpty ? '' : 'Tap row to seek apex', _corners.isEmpty ? '' : 'Tap row to seek apex',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: isLandscape ? 9 : 10,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface.withValues(alpha: 0.5), color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
), ),
@ -712,7 +739,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
hasLeanData hasLeanData
? Icons.straighten_rounded ? Icons.straighten_rounded
: Icons.sensors_off_rounded, : Icons.sensors_off_rounded,
size: 26, size: isLandscape ? 22 : 26,
color: hasLeanData color: hasLeanData
? colors.cyberCyan.withValues(alpha: 0.7) ? colors.cyberCyan.withValues(alpha: 0.7)
: colors.warningAmber.withValues(alpha: 0.8), : colors.warningAmber.withValues(alpha: 0.8),
@ -724,7 +751,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
: 'Sensor Fusion Uncalibrated / No Lean Telemetry', : 'Sensor Fusion Uncalibrated / No Lean Telemetry',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface.withValues(alpha: 0.85), color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -732,14 +759,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280), constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280),
child: Text( child: Text(
hasLeanData hasLeanData
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.' ? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
: 'Lean angle was not recorded or IMU was uncalibrated during this session.', : 'Lean angle was not recorded or IMU was uncalibrated during this session.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: isLandscape ? 10 : 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.55), color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
fontFamily: 'Inter', fontFamily: 'Inter',
), ),
@ -771,43 +798,49 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
}); });
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 14,
vertical: isLandscape ? 6 : 9,
),
color: isCurrent color: isCurrent
? colors.cyberCyan.withValues(alpha: 0.12) ? colors.cyberCyan.withValues(alpha: 0.12)
: Colors.transparent, : Colors.transparent,
child: Row( child: FittedBox(
children: [ fit: BoxFit.scaleDown,
// Corner ID & Direction alignment: Alignment.centerLeft,
SizedBox( child: Row(
width: 75, children: [
child: Row( // Corner ID & Direction
children: [ SizedBox(
Text( width: isLandscape ? 65 : 75,
'T${corner.id}', child: Row(
style: const TextStyle( children: [
fontWeight: FontWeight.bold, Text(
fontFamily: 'JetBrains Mono', 'T${corner.id}',
fontSize: 13, style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: isLandscape ? 12 : 13,
),
), ),
), const SizedBox(width: 4),
const SizedBox(width: 4), Text(
Text( corner.direction == 'Left' ? '↰ L' : '↱ R',
corner.direction == 'Left' ? '↰ L' : '↱ R', style: TextStyle(
style: TextStyle( fontSize: isLandscape ? 10 : 11,
fontSize: 11, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, color: corner.direction == 'Left'
color: corner.direction == 'Left' ? Colors.blueAccent
? Colors.blueAccent : Colors.greenAccent,
: Colors.greenAccent, fontFamily: 'JetBrains Mono',
fontFamily: 'JetBrains Mono', ),
), ),
), ],
], ),
), ),
), const SizedBox(width: 8),
// Entry Speed // Entry Speed
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
Text( Text(
'${corner.entrySpeed.toStringAsFixed(0)} mph', '${corner.entrySpeed.toStringAsFixed(0)} mph',
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'), style: TextStyle(
fontSize: isLandscape ? 11 : 12,
fontFamily: 'JetBrains Mono',
),
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Apex Speed & Lean // Apex Speed & Lean
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°', '${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colors.cyberCyan, color: colors.cyberCyan,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
@ -849,10 +884,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Exit Speed & Delta // Exit Speed & Delta
Expanded( Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -866,33 +900,34 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Text( Text(
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})', '${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: isLandscape ? 11 : 12,
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent, color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
), ),
), ),
], ],
), ),
), SizedBox(width: isLandscape ? 10 : 14),
// Grade Badge // Grade Badge
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: corner.getGradeColor().withValues(alpha: 0.15), color: corner.getGradeColor().withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)), border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
), ),
child: Text( child: Text(
corner.grade, corner.grade,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
color: corner.getGradeColor(), color: corner.getGradeColor(),
),
), ),
), ),
), ],
], ),
), ),
), ),
); );
@ -901,9 +936,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
), ),
], ],
), ),
), );
), }
],
if (isLandscape) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 5,
child: mapWidget,
),
Expanded(
flex: 4,
child: Column(
children: [
playbackControlsBar,
Expanded(
child: buildCornerTable(),
),
],
),
),
],
);
}
return Column(
children: [
Expanded(
flex: 5,
child: mapWidget,
),
playbackControlsBar,
Expanded(
flex: _corners.isEmpty ? 3 : 4,
child: buildCornerTable(),
),
],
);
},
), ),
); );
} }
@ -926,18 +998,28 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
); );
} }
Widget _buildOverlayStat(String label, String value) { Widget _buildOverlayStat(String label, String value, {bool isLandscape = false}) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 10 : 16,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black87, color: Colors.black87,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(isLandscape ? 8 : 12),
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
), ),
child: Column( child: Column(
children: [ children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)), Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)), Text(
value,
style: TextStyle(
fontSize: isLandscape ? 15 : 20,
fontWeight: FontWeight.bold,
color: Colors.cyanAccent,
),
),
], ],
), ),
); );
@ -950,6 +1032,7 @@ class _ReplayMapButton extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
final Color? color; final Color? color;
final Color? backgroundColor; final Color? backgroundColor;
final bool isCompact;
const _ReplayMapButton({ const _ReplayMapButton({
super.key, super.key,
@ -958,10 +1041,13 @@ class _ReplayMapButton extends StatelessWidget {
required this.onTap, required this.onTap,
this.color, this.color,
this.backgroundColor, this.backgroundColor,
this.isCompact = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = isCompact ? 36.0 : 48.0;
final iconSize = isCompact ? 18.0 : 24.0;
return Material( return Material(
color: backgroundColor ?? Colors.black87, color: backgroundColor ?? Colors.black87,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -974,12 +1060,12 @@ class _ReplayMapButton extends StatelessWidget {
child: Tooltip( child: Tooltip(
message: tooltip, message: tooltip,
child: SizedBox( child: SizedBox(
width: 48, width: size,
height: 48, height: size,
child: Icon( child: Icon(
icon, icon,
color: color ?? Colors.white, color: color ?? Colors.white,
size: 24, size: iconSize,
), ),
), ),
), ),

View File

@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxHeight = MediaQuery.of(context).size.height * 0.85; final size = MediaQuery.sizeOf(context);
final insets = MediaQuery.viewInsetsOf(context);
final isLandscape = size.width > size.height;
final maxHeight = (size.height - insets.bottom) * (isLandscape ? 0.95 : 0.85);
final crossAxisCount = size.width >= 720
? 4
: (size.width >= 480 ? 3 : 2);
return Container( return Container(
constraints: BoxConstraints(maxHeight: maxHeight), constraints: BoxConstraints(maxHeight: maxHeight),
@ -165,7 +171,12 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: EdgeInsets.fromLTRB(
16,
isLandscape ? 8 : 12,
16,
isLandscape ? 8 : 16,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -180,17 +191,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
), ),
), ),
const SizedBox(height: 12), SizedBox(height: isLandscape ? 6 : 12),
Row( Row(
children: [ children: [
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'REPORT ROAD INCIDENT', 'REPORT ROAD INCIDENT',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 16, fontSize: isLandscape ? 14 : 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 0.8, letterSpacing: 0.8,
color: Colors.white, color: Colors.white,
@ -199,23 +210,27 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white60), icon: const Icon(Icons.close, color: Colors.white60),
padding: isLandscape ? EdgeInsets.zero : const EdgeInsets.all(8.0),
constraints: isLandscape ? const BoxConstraints() : null,
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
], ],
), ),
const SizedBox(height: 4), if (!isLandscape) ...[
Text( const SizedBox(height: 4),
'1-tap broadcast to all nearby riders & pack members.', Text(
style: TextStyle( '1-tap broadcast to all nearby riders & pack members.',
fontFamily: AppTypography.fontBody, style: TextStyle(
fontSize: 12, fontFamily: AppTypography.fontBody,
color: Colors.white70, fontSize: 12,
color: Colors.white70,
),
), ),
), ],
if (_isMovingFast) ...[ if (_isMovingFast) ...[
const SizedBox(height: 10), SizedBox(height: isLandscape ? 6 : 10),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15), color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -223,7 +238,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
child: const Row( child: const Row(
children: [ children: [
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18), Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@ -239,22 +254,23 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
), ),
), ),
], ],
const SizedBox(height: 14), SizedBox(height: isLandscape ? 8 : 14),
Flexible( Flexible(
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: GridView.builder( child: GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: _kReportCategories.length, itemCount: _kReportCategories.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: crossAxisCount,
childAspectRatio: 2.2, mainAxisExtent: isLandscape ? 56.0 : 70.0,
crossAxisSpacing: 10, crossAxisSpacing: 8,
mainAxisSpacing: 10, mainAxisSpacing: 8,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _kReportCategories[index]; final item = _kReportCategories[index];
return _buildGridCard(item); return _buildGridCard(item, isLandscape: isLandscape);
}, },
), ),
), ),
@ -266,14 +282,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
); );
} }
Widget _buildGridCard(_ReportItem item) { Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) {
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: item.color.withValues(alpha: 0.12), backgroundColor: item.color.withValues(alpha: 0.12),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 6 : 10,
vertical: isLandscape ? 4 : 8,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5), side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
), ),
elevation: 0, elevation: 0,
@ -282,14 +301,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(7), padding: EdgeInsets.all(isLandscape ? 5 : 7),
decoration: BoxDecoration( decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.22), color: item.color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(item.icon, color: item.color, size: 22), child: Icon(item.icon, color: item.color, size: isLandscape ? 18 : 22),
), ),
const SizedBox(width: 8), SizedBox(width: isLandscape ? 6 : 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -299,20 +318,22 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
item.title, item.title,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: 13, fontSize: isLandscape ? 11.5 : 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), if (!isLandscape) ...[
Text( const SizedBox(height: 2),
item.subtitle, Text(
style: const TextStyle(fontSize: 10, color: Colors.white70), item.subtitle,
maxLines: 1, style: const TextStyle(fontSize: 10, color: Colors.white70),
overflow: TextOverflow.ellipsis, maxLines: 1,
), overflow: TextOverflow.ellipsis,
),
],
], ],
), ),
), ),

View File

@ -317,6 +317,28 @@ void main() {
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
}); });
} }
testWidgets('BUG-012: HistoryScreen ride cards show all bottom row telemetry without overflow on 800x360 landscape', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const HistoryScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('history_ride_card_1')), findsOneWidget);
expect(find.text('TRIP'), findsOneWidget);
expect(find.text('TIME'), findsOneWidget);
expect(find.text('TOP SPD'), findsOneWidget);
expect(find.text('MAX LEAN'), findsOneWidget);
expect(find.text('HARD BRK'), findsOneWidget);
expect(find.text('ELEV GAIN'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}); });
} }

View File

@ -20,6 +20,8 @@ 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/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/quick_squad_feed_button.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
@ -166,5 +168,81 @@ void main() {
final edgeInsets = paddingWidget.padding as EdgeInsets; final edgeInsets = paddingWidget.padding as EdgeInsets;
expect(edgeInsets.right, greaterThanOrEqualTo(16.0)); expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
}); });
testWidgets('BUG-007: Short landscape cockpit mount includes incident report and squad buttons with balanced layout', (tester) async {
configureViewport(tester, 800, 360);
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byType(QuickSwarmReportButton), findsOneWidget);
expect(find.byType(QuickSquadFeedButton), findsOneWidget);
expect(find.byType(SpeedReadout), findsOneWidget);
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible without clipping in minimal landscape (map hidden)', (tester) async {
await prefs.setString('dashboardLayout', 'minimal');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(840, 390)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final startBtnText = find.text('Start Session');
expect(startBtnText, findsOneWidget);
// Verify button text has positive width and fits cleanly
final textRenderBox = tester.renderObject(startBtnText) as RenderBox;
expect(textRenderBox.size.width, greaterThan(0));
expect(textRenderBox.size.height, greaterThan(0));
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: Start Session button text is fully visible in sport and touring landscape layouts', (tester) async {
for (final layout in ['sport', 'touring']) {
await prefs.setString('dashboardLayout', layout);
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Start Session'), findsOneWidget);
}
}
tester.view.resetPhysicalSize();
});
testWidgets('BUG-007 / #54: LEAN ANGLE title does not overlap rider graphic across landscape viewports', (tester) async {
await prefs.setString('dashboardLayout', 'sport');
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
configureViewport(tester, size.width, size.height);
await tester.pumpWidget(buildTestDashboard());
await tester.pump();
expect(tester.takeException(), isNull);
final titleFinder = find.text('LEAN ANGLE');
final graphicFinder = find.byKey(const ValueKey('lean_angle_rider_graphic'));
expect(titleFinder, findsOneWidget);
expect(graphicFinder, findsOneWidget);
final titleRect = tester.getRect(titleFinder);
final graphicRect = tester.getRect(graphicFinder);
// Prove no overlap between title rect and rider graphic rect
expect(titleRect.overlaps(graphicRect), isFalse, reason: 'Title overlaps rider graphic at ${size.width}x${size.height}');
// Prove title is positioned strictly above graphic
expect(titleRect.bottom, lessThanOrEqualTo(graphicRect.top), reason: 'Title bottom must be above or at graphic top at ${size.width}x${size.height}');
}
tester.view.resetPhysicalSize();
});
}); });
} }

View File

@ -0,0 +1,172 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/map/map_screen.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
class _TestHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) => _MockHttpClient();
}
class _MockHttpHeaders extends Fake implements HttpHeaders {
@override
void add(String name, Object value, {bool preserveHeaderCase = false}) {}
@override
void set(String name, Object value, {bool preserveHeaderCase = false}) {}
}
class _MockHttpClient extends Fake implements HttpClient {
@override
Future<HttpClientRequest> getUrl(Uri url) async => _MockHttpRequest();
@override
Future<HttpClientRequest> openUrl(String method, Uri url) async => _MockHttpRequest();
@override
void close({bool force = false}) {}
}
class _MockHttpRequest extends Fake implements HttpClientRequest {
@override
final HttpHeaders headers = _MockHttpHeaders();
@override
bool followRedirects = true;
@override
int maxRedirects = 5;
@override
int contentLength = -1;
@override
Future<HttpClientResponse> close() async => _MockHttpResponse();
}
class _MockHttpResponse extends Fake implements HttpClientResponse {
@override
int get statusCode => 200;
@override
int get contentLength => _transparent1x1Png.length;
@override
HttpClientResponseCompressionState get compressionState =>
HttpClientResponseCompressionState.notCompressed;
@override
StreamSubscription<List<int>> listen(
void Function(List<int> event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) {
return Stream<List<int>>.value(_transparent1x1Png).listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}
}
final _transparent1x1Png = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
setUp(() async {
HttpOverrides.global = _TestHttpOverrides();
db = AppDatabase.forTesting(NativeDatabase.memory());
// Insert sample ride and telemetry breadcrumbs
await db.into(db.rides).insert(
RidesCompanion.insert(
id: const drift.Value(1),
startTime: DateTime(2026, 1, 1, 10, 0),
distanceMiles: const drift.Value(15.2),
topSpeedMph: const drift.Value(75.0),
movingTime: const drift.Value(900),
stoppedTime: const drift.Value(60),
),
);
for (int i = 0; i < 10; i++) {
await db.into(db.telemetrySamples).insert(
TelemetrySamplesCompanion.insert(
rideId: 1,
timestamp: DateTime(2026, 1, 1, 10, 0, i * 5),
latitude: drift.Value(37.3861 + (i * 0.002)),
longitude: drift.Value(-122.2641 + (i * 0.002)),
speed: drift.Value(35.0 + i),
leanAngle: drift.Value(12.0),
),
);
}
});
tearDown(() async {
HttpOverrides.global = null;
await db.close();
});
Widget buildSubject() {
return ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const MapScreen(rideId: 1),
),
);
}
testWidgets('Portrait: renders both appbar action and floating replay action button', (tester) async {
tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildSubject());
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Landscape: removes duplicate floating replay action to keep map unobstructed (#60)', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildSubject());
await tester.pumpAndSettle();
// Appbar replay action remains accessible
expect(find.byKey(const ValueKey('play_replay_appbar_button')), findsOneWidget);
// Floating button is hidden in landscape
expect(find.byKey(const ValueKey('fire_off_ride_preview_button')), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}

View File

@ -118,6 +118,22 @@ void main() {
expect(find.text('0–100 MPH'), findsOneWidget); expect(find.text('0–100 MPH'), findsOneWidget);
}); });
testWidgets('BUG-009: Renders cleanly without clipping on 800x360 landscape phone', (tester) async {
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
expect(find.text('Auto Launch'), findsOneWidget);
expect(find.text('Countdown'), findsOneWidget);
expect(find.text('0–60 MPH'), findsOneWidget);
expect(find.text('0–100 MPH'), findsOneWidget);
});
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async { testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -198,6 +214,21 @@ void main() {
expect(find.text('Tap below to initiate countdown'), findsOneWidget); expect(find.text('Tap below to initiate countdown'), findsOneWidget);
}); });
testWidgets('BUG-010: Countdown READY renders without 68px overflow on 800x360 landscape phone', (tester) async {
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
await tester.pumpAndSettle();
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
await tester.pump(const Duration(milliseconds: 20));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
expect(find.text('Start Countdown'), findsOneWidget);
});
testWidgets('8. READY survives widget rebuild', (tester) async { testWidgets('8. READY survives widget rebuild', (tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -329,5 +329,18 @@ void main() {
expect(alerts.reports, isEmpty); expect(alerts.reports, isEmpty);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('BUG-008: renders cleanly without overflow on 800x360 landscape phone', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await open(tester);
expect(tester.takeException(), isNull);
expect(find.text('REPORT ROAD INCIDENT'), findsOneWidget);
expect(find.text('Police'), findsOneWidget);
expect(find.text('Road Hazard'), findsOneWidget);
expect(find.text('Weather Hazard'), findsOneWidget);
});
} }

View File

@ -165,4 +165,63 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget); expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
}); });
testWidgets('BUG-014 (#61): ReplayScreen renders responsive horizontal split in 800x360 landscape without overflow', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
],
child: const MaterialApp(
home: Scaffold(
body: ReplayScreen(rideId: 1),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Verifies both map and breakdown table are rendered concurrently in viewport
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
expect(find.text('SPEED'), findsOneWidget);
expect(find.text('LEAN'), findsOneWidget);
expect(find.text('G'), findsOneWidget);
expect(find.byType(Slider), findsOneWidget);
expect(find.byType(Row), findsWidgets);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('BUG-014 (#61): ReplayScreen renders without overflow on 640x320 compact landscape', (tester) async {
tester.view.physicalSize = const Size(640, 320);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
],
child: const MaterialApp(
home: Scaffold(
body: ReplayScreen(rideId: 1),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
} }