From 3f64083bdd3b91fc190d38c0d28696070057ed28 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Sat, 17 Oct 2026 19:06:30 -0400 Subject: [PATCH] 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 --- .../widgets/hybrid_riding_action_bar.dart | 25 +- lib/core/widgets/main_layout.dart | 81 +-- .../widgets/lean_angle_indicator.dart | 28 +- .../widgets/responsive_dashboard_layout.dart | 42 +- lib/features/history/history_screen.dart | 13 +- lib/features/map/map_screen.dart | 4 +- .../performance_launch_screen.dart | 337 ++++++------ lib/features/replay/replay_screen.dart | 484 +++++++++++------- .../widgets/quick_swarm_report_modal.dart | 95 ++-- ...istory_records_garage_responsive_test.dart | 22 + test/dashboard_landscape_responsive_test.dart | 78 +++ test/map_replay_duplicate_action_test.dart | 172 +++++++ test/performance_launch_screen_test.dart | 31 ++ test/quick_swarm_report_modal_test.dart | 13 + test/replay_screen_responsive_test.dart | 59 +++ 15 files changed, 1019 insertions(+), 465 deletions(-) create mode 100644 test/map_replay_duplicate_action_test.dart diff --git a/lib/core/widgets/hybrid_riding_action_bar.dart b/lib/core/widgets/hybrid_riding_action_bar.dart index 86ac42f..b0c0a67 100644 --- a/lib/core/widgets/hybrid_riding_action_bar.dart +++ b/lib/core/widgets/hybrid_riding_action_bar.dart @@ -223,6 +223,8 @@ class HybridRidingActionBar extends ConsumerWidget { fit: BoxFit.scaleDown, child: Text( finishLabel ?? 'Finish Ride', + maxLines: 1, + softWrap: false, style: const TextStyle( fontFamily: AppTypography.fontHeadline, fontSize: 15, @@ -238,7 +240,6 @@ class HybridRidingActionBar extends ConsumerWidget { ); } - // Ready / Standby state: Single prominent start button return SizedBox( width: double.infinity, @@ -253,18 +254,24 @@ class HybridRidingActionBar extends ConsumerWidget { borderRadius: BorderRadius.circular(14), ), elevation: 0, + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12), ), onPressed: () { notifier.startRide(); }, - icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan), - label: const Text( - 'Start Session', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 17, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, + icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan), + label: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Start Session', + maxLines: 1, + softWrap: false, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 17, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), ), ), ), diff --git a/lib/core/widgets/main_layout.dart b/lib/core/widgets/main_layout.dart index fd9a1c7..8f610a7 100644 --- a/lib/core/widgets/main_layout.dart +++ b/lib/core/widgets/main_layout.dart @@ -138,45 +138,48 @@ class _MainLayoutState extends ConsumerState { minHeight: constraints.maxHeight, ), child: IntrinsicHeight( - child: NavigationRail( - minWidth: 58, - groupAlignment: 0.0, - selectedIndex: selectedIndex, - onDestinationSelected: _onDestinationSelected, - backgroundColor: colors.surfaceCanvas, - indicatorColor: colors.brandCyan.withValues(alpha: 0.14), - selectedIconTheme: IconThemeData( - color: colors.brandCyan, - size: 21, - ), - unselectedIconTheme: IconThemeData( - color: colors.textMuted, - size: 19, - ), - selectedLabelTextStyle: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 10.0, - fontWeight: FontWeight.w700, - color: colors.brandCyan, - ), - unselectedLabelTextStyle: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 9.5, - fontWeight: FontWeight.w500, - color: colors.textMuted, - ), - labelType: NavigationRailLabelType.all, - destinations: kAppNavDestinations.map((dest) { - return NavigationRailDestination( - padding: const EdgeInsets.symmetric(vertical: 4), - icon: Icon(dest.icon, key: dest.railKey), - selectedIcon: dest.selectedIcon != null - ? Icon(dest.selectedIcon, key: dest.railKey) - : null, - label: Text(dest.label), - ); - }).toList(), - ), + child: () { + final isShortRail = constraints.maxHeight < 400; + return NavigationRail( + minWidth: isShortRail ? 52 : 58, + groupAlignment: isShortRail ? -1.0 : 0.0, + selectedIndex: selectedIndex, + onDestinationSelected: _onDestinationSelected, + backgroundColor: colors.surfaceCanvas, + indicatorColor: colors.brandCyan.withValues(alpha: 0.14), + selectedIconTheme: IconThemeData( + color: colors.brandCyan, + size: isShortRail ? 19 : 21, + ), + unselectedIconTheme: IconThemeData( + color: colors.textMuted, + size: isShortRail ? 17 : 19, + ), + selectedLabelTextStyle: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: isShortRail ? 9.0 : 10.0, + fontWeight: FontWeight.w700, + color: colors.brandCyan, + ), + unselectedLabelTextStyle: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: isShortRail ? 8.5 : 9.5, + fontWeight: FontWeight.w500, + color: colors.textMuted, + ), + labelType: NavigationRailLabelType.all, + destinations: kAppNavDestinations.map((dest) { + return NavigationRailDestination( + padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4), + icon: Icon(dest.icon, key: dest.railKey), + selectedIcon: dest.selectedIcon != null + ? Icon(dest.selectedIcon, key: dest.railKey) + : null, + label: Text(dest.label), + ); + }).toList(), + ); + }(), ), ), ), diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart index 4901ffd..1036e5f 100644 --- a/lib/features/dashboard/widgets/lean_angle_indicator.dart +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -233,6 +233,7 @@ class LeanAngleIndicator extends ConsumerWidget { // Header Text( 'LEAN ANGLE', + key: const ValueKey('lean_angle_title'), style: TextStyle( color: onSurfaceVariant, fontSize: compact ? 9 : 11, @@ -240,22 +241,25 @@ class LeanAngleIndicator extends ConsumerWidget { letterSpacing: 1.2, ), ), - SizedBox(height: compact ? 1 : 3), + SizedBox(height: compact ? 3 : 6), // Motorcycle Leaning Silhouette with Stable Horizon RepaintBoundary( child: SizedBox( + key: const ValueKey('lean_angle_rider_graphic'), width: gaugeWidth, height: gaugeHeight, - child: CustomPaint( - size: Size(gaugeWidth, gaugeHeight), - painter: _MotorcycleLeanPainter( - leanAngle: displayLean, - motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0), - accentColor: colors.brandCyan, - horizonColor: isDark ? Colors.white24 : Colors.black26, - tickColor: colors.brandCyan, - isDark: isDark, + child: ClipRect( + child: CustomPaint( + size: Size(gaugeWidth, gaugeHeight), + painter: _MotorcycleLeanPainter( + leanAngle: displayLean, + motorcycleColor: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0), + accentColor: colors.brandCyan, + horizonColor: isDark ? Colors.white24 : Colors.black26, + tickColor: colors.brandCyan, + isDark: isDark, + ), ), ), ), @@ -426,6 +430,10 @@ class _MotorcycleLeanPainter extends CustomPainter { // 2. Draw Leaning Motorcycle Silhouette canvas.save(); 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); canvas.rotate(clampedLean * math.pi / 180.0); diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index 9450e1f..db0f979 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -326,17 +326,29 @@ class ResponsiveDashboardLayout extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - DashboardLayoutSwitcher(), - GPSStatusIndicator(showLabel: false), - HeadingIndicator(compact: true), - RideStatusChip(), - ], + const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DashboardLayoutSwitcher(), + 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( - child: SpeedReadout(size: SpeedReadoutSize.compact), + const Expanded( + child: Center( + child: SpeedReadout(size: SpeedReadoutSize.compact), + ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -386,7 +398,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget { child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)), ), SizedBox( - width: 140, + width: 164, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -463,6 +475,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget { children: [ DashboardLayoutSwitcher(), SizedBox(width: 4), + QuickSwarmReportButton(compact: true), + SizedBox(width: 4), + QuickSquadFeedButton(compact: true), + SizedBox(width: 4), GPSStatusIndicator(), SizedBox(width: 4), HeadingIndicator(compact: true), @@ -472,7 +488,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), ), 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 SizedBox(height: 4), const PerformanceLaunchShortcut(compact: true), diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 01daf66..cead2aa 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState { padding: const EdgeInsets.all(12), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 600, - mainAxisExtent: 175, + mainAxisExtent: 196, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: rides.length, 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 { ); } - Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) { + Widget _buildRideCard( + BuildContext context, + Ride ride, + MotorSignalThemeColors colors, { + bool isGrid = false, + }) { final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null) @@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState { return Card( elevation: 2, - margin: const EdgeInsets.symmetric(vertical: 6), + margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: colors.outlineVariant), diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 86e50ed..d5d7094 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState { ), ), - // Bottom Center: FIRE OFF REPLAY floating button - if (_routePoints.isNotEmpty) + // Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used) + if (_routePoints.isNotEmpty && !responsive.isLandscape) Positioned( bottom: 24, left: 20, diff --git a/lib/features/performance/performance_launch_screen.dart b/lib/features/performance/performance_launch_screen.dart index c2989e8..26a2c51 100644 --- a/lib/features/performance/performance_launch_screen.dart +++ b/lib/features/performance/performance_launch_screen.dart @@ -182,21 +182,23 @@ class _SetupView extends StatelessWidget { @override Widget build(BuildContext context) { - final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport; + final isLandscape = snapshot.isLandscape; - if (isShortLandscape) { + if (isLandscape) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildModeSelector(context), - const SizedBox(height: 12), - _buildTargetSelector(context), + _buildModeSelector(context, isLandscape: true), + const SizedBox(height: 8), + _buildTargetSelector(context, isLandscape: true), ], ), ), @@ -204,12 +206,13 @@ class _SetupView extends StatelessWidget { const SizedBox(width: 16), Expanded( child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildExplanationCard(context), - const SizedBox(height: 12), - _buildArmButton(context), + _buildExplanationCard(context, isLandscape: true), + const SizedBox(height: 10), + _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( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -244,13 +247,13 @@ class _SetupView extends StatelessWidget { 'START MODE', style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 12, + fontSize: isLandscape ? 11 : 12, fontWeight: FontWeight.w700, letterSpacing: 1.0, color: isDark ? Colors.white60 : Colors.black54, ), ), - const SizedBox(height: 8), + SizedBox(height: isLandscape ? 6 : 8), Row( children: [ Expanded( @@ -263,6 +266,7 @@ class _SetupView extends StatelessWidget { onTap: () => onModeChanged(PerformanceLaunchMode.auto), colors: colors, isDark: isDark, + isLandscape: isLandscape, ), ), const SizedBox(width: 8), @@ -276,6 +280,7 @@ class _SetupView extends StatelessWidget { onTap: () => onModeChanged(PerformanceLaunchMode.countdown), colors: colors, 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -292,13 +297,13 @@ class _SetupView extends StatelessWidget { 'BENCHMARK TARGET', style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 12, + fontSize: isLandscape ? 11 : 12, fontWeight: FontWeight.w700, letterSpacing: 1.0, color: isDark ? Colors.white60 : Colors.black54, ), ), - const SizedBox(height: 8), + SizedBox(height: isLandscape ? 6 : 8), Row( children: [ Expanded( @@ -311,6 +316,7 @@ class _SetupView extends StatelessWidget { onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty), colors: colors, isDark: isDark, + isLandscape: isLandscape, ), ), const SizedBox(width: 8), @@ -324,6 +330,7 @@ class _SetupView extends StatelessWidget { onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred), colors: colors, 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 ? '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.'; return Container( - padding: const EdgeInsets.all(14), + padding: EdgeInsets.all(isLandscape ? 10 : 14), decoration: BoxDecoration( color: colors.surfaceContainer, borderRadius: BorderRadius.circular(12), @@ -349,17 +356,17 @@ class _SetupView extends StatelessWidget { children: [ Icon( Icons.info_outline, - size: 20, + size: isLandscape ? 18 : 20, color: colors.velocityBlue, ), - const SizedBox(width: 10), + SizedBox(width: isLandscape ? 8 : 10), Expanded( child: Text( text, style: TextStyle( fontFamily: AppTypography.fontBody, - fontSize: 13, - height: 1.35, + fontSize: isLandscape ? 11.5 : 13, + height: 1.3, 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( - height: 52, + height: isLandscape ? 44 : 52, + width: double.infinity, child: ElevatedButton( key: const ValueKey('btn_arm_launch'), style: ElevatedButton.styleFrom( backgroundColor: colors.launchGreen, foregroundColor: Colors.black, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 2, ), onPressed: onArm, @@ -385,7 +393,7 @@ class _SetupView extends StatelessWidget { 'Arm Launch', style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.w700, letterSpacing: 0.5, ), @@ -403,6 +411,7 @@ class _SelectionButton extends StatelessWidget { final VoidCallback onTap; final MotorSignalThemeColors colors; final bool isDark; + final bool isLandscape; const _SelectionButton({ super.key, @@ -413,6 +422,7 @@ class _SelectionButton extends StatelessWidget { required this.onTap, required this.colors, required this.isDark, + this.isLandscape = false, }); @override @@ -428,7 +438,10 @@ class _SelectionButton extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 8 : 12, + vertical: isLandscape ? 8 : 12, + ), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(12), @@ -439,27 +452,31 @@ class _SelectionButton extends StatelessWidget { children: [ Icon( icon, - size: 22, + size: isLandscape ? 18 : 22, color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54), ), - const SizedBox(height: 8), + SizedBox(height: isLandscape ? 4 : 8), Text( title, style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 15, + fontSize: isLandscape ? 13 : 15, fontWeight: FontWeight.w800, color: isDark ? Colors.white : Colors.black87, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( subtitle, style: TextStyle( fontFamily: AppTypography.fontBody, - fontSize: 11, + fontSize: isLandscape ? 10 : 11, color: isDark ? Colors.white60 : Colors.black54, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), @@ -508,44 +525,48 @@ class _WaitingView extends ConsumerWidget { statusColor = colors.velocityBlue; } - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(statusIcon, size: 54, color: statusColor), - const SizedBox(height: 20), - Text( - statusText, - key: const ValueKey('waiting_status_text'), - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 18, - fontWeight: FontWeight.w800, - letterSpacing: 1.0, - color: statusColor, + final isLandscape = snapshot.isLandscape; + return Center( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.all(isLandscape ? 16 : 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor), + SizedBox(height: isLandscape ? 10 : 20), + Text( + statusText, + key: const ValueKey('waiting_status_text'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: isLandscape ? 15 : 18, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + color: statusColor, + ), ), - ), - const SizedBox(height: 12), - Text( - 'Current speed: ${speed.toStringAsFixed(1)} MPH', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - color: isDark ? Colors.white70 : Colors.black54, + SizedBox(height: isLandscape ? 6 : 12), + Text( + 'Current speed: ${speed.toStringAsFixed(1)} MPH', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: isLandscape ? 12 : 14, + color: isDark ? Colors.white70 : Colors.black54, + ), ), - ), - const SizedBox(height: 32), - OutlinedButton( - key: const ValueKey('btn_cancel_waiting'), - onPressed: () { - ref.read(performanceLaunchProvider.notifier).reset(); - }, - child: const Text('Cancel'), - ), - ], + SizedBox(height: isLandscape ? 16 : 32), + OutlinedButton( + key: const ValueKey('btn_cancel_waiting'), + onPressed: () { + ref.read(performanceLaunchProvider.notifier).reset(); + }, + child: const Text('Cancel'), + ), + ], + ), ), ); } @@ -570,108 +591,118 @@ class _ReadyView extends ConsumerWidget { final mode = ref.watch(performanceLaunchProvider.select((s) => s.mode)); final target = ref.watch(performanceLaunchProvider.select((s) => s.target)); final isCountdown = mode == PerformanceLaunchMode.countdown; + final isLandscape = snapshot.isLandscape; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), - decoration: BoxDecoration( - color: colors.launchGreen.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colors.launchGreen, width: 2.0), + return Center( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 32 : 20, + vertical: isLandscape ? 8 : 16, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + 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, - children: [ - Icon(Icons.check_circle, color: colors.launchGreen, size: 28), - const SizedBox(width: 10), - Text( - 'READY', - key: const ValueKey('ready_status_badge'), + ), + SizedBox(height: isLandscape ? 8 : 20), + Text( + target.label, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + 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( fontFamily: AppTypography.fontHeadline, - fontSize: 28, - fontWeight: FontWeight.w900, - letterSpacing: 2.0, - color: colors.launchGreen, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, ), ), - ], - ), - ), - ), - 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: () { - ref.read(performanceLaunchProvider.notifier).startCountdown(); + ref.read(performanceLaunchProvider.notifier).reset(); }, - child: const Text( - 'Start Countdown', + child: Text( + 'Cancel & Change Setup', style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, + color: isDark ? Colors.white60 : Colors.black54, ), ), ), ), - 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, - ), - ), - ), - ), - ], + ), ), ); } diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart index ae3f55a..92b908e 100644 --- a/lib/features/replay/replay_screen.dart +++ b/lib/features/replay/replay_screen.dart @@ -7,6 +7,7 @@ import '../../database/database.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; import '../../core/theme/theme_extensions.dart'; +import '../../core/responsive/responsive_layout.dart'; /// Corner performance metrics and apex grading for forensic telemetry replay. class CornerAnalysis { @@ -474,117 +475,120 @@ class _ReplayScreenState extends ConsumerState { ), ], ), - body: Column( - children: [ - // Map View (with multi-color lean heatmap and telemetry HUD overlay) - Expanded( - flex: 5, - child: Stack( - children: [ - UnifiedMapView( - center: currentPos, - zoom: _zoom, - markers: allMarkers, - polylines: polylines, - isFollowModeOverride: _isFollowingRider, - onUserInteraction: () { - if (_isFollowingRider) { - setState(() => _isFollowingRider = false); - } - }, - ), - // Map Navigation Controls (Zoom In, Zoom Out, Recenter) - Positioned( - right: 14, - bottom: 14, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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), - ], + body: ResponsiveLayout( + builder: (context, snapshot) { + final isLandscape = snapshot.isLandscape; + + final mapWidget = Stack( + children: [ + UnifiedMapView( + center: currentPos, + zoom: _zoom, + markers: allMarkers, + polylines: polylines, + isFollowModeOverride: _isFollowingRider, + onUserInteraction: () { + if (_isFollowingRider) { + setState(() => _isFollowingRider = false); + } + }, + ), + // Map Navigation Controls (Zoom In, Zoom Out, Recenter) + Positioned( + right: isLandscape ? 10 : 14, + bottom: isLandscape ? 10 : 14, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_isFollowingRider) ...[ _ReplayMapButton( - key: const ValueKey('replay_zoom_in_button'), - icon: Icons.add, - tooltip: 'Zoom In', - onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)), - ), - const SizedBox(height: 8), - _ReplayMapButton( - key: const ValueKey('replay_zoom_out_button'), - icon: Icons.remove, - tooltip: 'Zoom Out', - onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)), + key: const ValueKey('replay_recenter_button'), + icon: Icons.my_location, + tooltip: 'Recenter on Rider', + color: colors.cyberCyan, + backgroundColor: colors.cyberCyan.withValues(alpha: 0.25), + isCompact: isLandscape, + onTap: () => setState(() => _isFollowingRider = true), ), + 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( - top: 14, - left: 14, - right: 14, - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, + ), + // Heatmap Legend Overlay + Positioned( + bottom: isLandscape ? 8 : 10, + left: isLandscape ? 8 : 14, + right: isLandscape ? 60 : 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( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, children: [ - _buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'), + _buildLegendItem(Colors.amberAccent, '≤20°'), const SizedBox(width: 8), - _buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'), + _buildLegendItem(Colors.cyanAccent, '20°-35°'), 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 - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + final playbackControlsBar = Container( + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 12 : 16, + vertical: isLandscape ? 4 : 8, + ), color: theme.colorScheme.surface, child: Column( mainAxisSize: MainAxisSize.min, @@ -596,10 +600,10 @@ class _ReplayScreenState extends ConsumerState { child: Text( currentSample.timestamp.toLocal().toString().split('.')[0], overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: Colors.cyanAccent, fontWeight: FontWeight.bold, - fontSize: 12, + fontSize: isLandscape ? 11 : 12, fontFamily: 'JetBrains Mono', ), ), @@ -609,30 +613,42 @@ class _ReplayScreenState extends ConsumerState { 'Sample ${_currentIndex + 1} / ${_telemetry.length}', style: TextStyle( color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 11, + fontSize: isLandscape ? 10 : 11, fontFamily: 'JetBrains Mono', ), ), ], ), - Slider( - value: currentSliderValue, - min: 0, - max: maxSliderValue, - onChanged: _telemetry.length > 1 - ? (val) { - setState(() { - _currentIndex = val.toInt(); - }); - } - : null, - activeColor: Colors.cyanAccent, + SliderTheme( + data: isLandscape + ? SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ) + : SliderTheme.of(context), + child: Slider( + value: currentSliderValue, + min: 0, + max: maxSliderValue, + onChanged: _telemetry.length > 1 + ? (val) { + setState(() { + _currentIndex = val.toInt(); + }); + } + : null, + activeColor: Colors.cyanAccent, + ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ 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', onPressed: () { setState(() { @@ -640,35 +656,46 @@ class _ReplayScreenState extends ConsumerState { }); }, ), - const SizedBox(width: 16), + SizedBox(width: isLandscape ? 8 : 16), FloatingActionButton.small( 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( + style: isLandscape + ? TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ) + : null, onPressed: _changeSpeed, child: Text( '${_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 - Expanded( - flex: _corners.isEmpty ? 3 : 4, - child: Container( + Widget buildCornerTable() { + return Container( color: theme.colorScheme.surfaceContainerLow, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 10 : 14, + vertical: isLandscape ? 6 : 8, + ), color: theme.colorScheme.surfaceContainer, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -678,7 +705,7 @@ class _ReplayScreenState extends ConsumerState { 'CORNER APEX BREAKDOWN & GRADING', overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 11, + fontSize: isLandscape ? 10 : 11, fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono', color: colors.cyberCyan, @@ -690,7 +717,7 @@ class _ReplayScreenState extends ConsumerState { Text( _corners.isEmpty ? '' : 'Tap row to seek apex', style: TextStyle( - fontSize: 10, + fontSize: isLandscape ? 9 : 10, fontFamily: 'JetBrains Mono', color: theme.colorScheme.onSurface.withValues(alpha: 0.5), ), @@ -712,7 +739,7 @@ class _ReplayScreenState extends ConsumerState { hasLeanData ? Icons.straighten_rounded : Icons.sensors_off_rounded, - size: 26, + size: isLandscape ? 22 : 26, color: hasLeanData ? colors.cyberCyan.withValues(alpha: 0.7) : colors.warningAmber.withValues(alpha: 0.8), @@ -724,7 +751,7 @@ class _ReplayScreenState extends ConsumerState { : 'Sensor Fusion Uncalibrated / No Lean Telemetry', textAlign: TextAlign.center, style: TextStyle( - fontSize: 12, + fontSize: isLandscape ? 11 : 12, fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface.withValues(alpha: 0.85), fontFamily: 'JetBrains Mono', @@ -732,14 +759,14 @@ class _ReplayScreenState extends ConsumerState { ), const SizedBox(height: 4), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), + constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280), child: Text( hasLeanData ? 'Upright riding recorded. Lean angle remained below 15° throughout this session.' : 'Lean angle was not recorded or IMU was uncalibrated during this session.', textAlign: TextAlign.center, style: TextStyle( - fontSize: 11, + fontSize: isLandscape ? 10 : 11, color: theme.colorScheme.onSurface.withValues(alpha: 0.55), fontFamily: 'Inter', ), @@ -771,43 +798,49 @@ class _ReplayScreenState extends ConsumerState { }); }, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 10 : 14, + vertical: isLandscape ? 6 : 9, + ), color: isCurrent ? colors.cyberCyan.withValues(alpha: 0.12) : Colors.transparent, - child: Row( - children: [ - // Corner ID & Direction - SizedBox( - width: 75, - child: Row( - children: [ - Text( - 'T${corner.id}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'JetBrains Mono', - fontSize: 13, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + children: [ + // Corner ID & Direction + SizedBox( + width: isLandscape ? 65 : 75, + child: Row( + children: [ + Text( + 'T${corner.id}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'JetBrains Mono', + fontSize: isLandscape ? 12 : 13, + ), ), - ), - const SizedBox(width: 4), - Text( - corner.direction == 'Left' ? '↰ L' : '↱ R', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: corner.direction == 'Left' - ? Colors.blueAccent - : Colors.greenAccent, - fontFamily: 'JetBrains Mono', + const SizedBox(width: 4), + Text( + corner.direction == 'Left' ? '↰ L' : '↱ R', + style: TextStyle( + fontSize: isLandscape ? 10 : 11, + fontWeight: FontWeight.bold, + color: corner.direction == 'Left' + ? Colors.blueAccent + : Colors.greenAccent, + fontFamily: 'JetBrains Mono', + ), ), - ), - ], + ], + ), ), - ), - // Entry Speed - Expanded( - child: Column( + const SizedBox(width: 8), + // Entry Speed + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState { ), Text( '${corner.entrySpeed.toStringAsFixed(0)} mph', - style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'), + style: TextStyle( + fontSize: isLandscape ? 11 : 12, + fontFamily: 'JetBrains Mono', + ), ), ], ), - ), - // Apex Speed & Lean - Expanded( - child: Column( + SizedBox(width: isLandscape ? 10 : 14), + // Apex Speed & Lean + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState { Text( '${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°', style: TextStyle( - fontSize: 12, + fontSize: isLandscape ? 11 : 12, fontWeight: FontWeight.bold, color: colors.cyberCyan, fontFamily: 'JetBrains Mono', @@ -849,10 +884,9 @@ class _ReplayScreenState extends ConsumerState { ), ], ), - ), - // Exit Speed & Delta - Expanded( - child: Column( + SizedBox(width: isLandscape ? 10 : 14), + // Exit Speed & Delta + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -866,33 +900,34 @@ class _ReplayScreenState extends ConsumerState { Text( '${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})', style: TextStyle( - fontSize: 12, + fontSize: isLandscape ? 11 : 12, color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent, fontFamily: 'JetBrains Mono', ), ), ], ), - ), - // Grade Badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: corner.getGradeColor().withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)), - ), - child: Text( - corner.grade, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - fontFamily: 'JetBrains Mono', - color: corner.getGradeColor(), + SizedBox(width: isLandscape ? 10 : 14), + // Grade Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: corner.getGradeColor().withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)), + ), + child: Text( + corner.grade, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + fontFamily: 'JetBrains Mono', + color: corner.getGradeColor(), + ), ), ), - ), - ], + ], + ), ), ), ); @@ -901,9 +936,46 @@ class _ReplayScreenState extends ConsumerState { ), ], ), - ), - ), - ], + ); + } + + 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 { ); } - Widget _buildOverlayStat(String label, String value) { + Widget _buildOverlayStat(String label, String value, {bool isLandscape = false}) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 10 : 16, + vertical: isLandscape ? 4 : 8, + ), decoration: BoxDecoration( color: Colors.black87, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(isLandscape ? 8 : 12), border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), ), child: Column( children: [ - Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)), - Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)), + Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)), + 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 Color? color; final Color? backgroundColor; + final bool isCompact; const _ReplayMapButton({ super.key, @@ -958,10 +1041,13 @@ class _ReplayMapButton extends StatelessWidget { required this.onTap, this.color, this.backgroundColor, + this.isCompact = false, }); @override Widget build(BuildContext context) { + final size = isCompact ? 36.0 : 48.0; + final iconSize = isCompact ? 18.0 : 24.0; return Material( color: backgroundColor ?? Colors.black87, shape: RoundedRectangleBorder( @@ -974,12 +1060,12 @@ class _ReplayMapButton extends StatelessWidget { child: Tooltip( message: tooltip, child: SizedBox( - width: 48, - height: 48, + width: size, + height: size, child: Icon( icon, color: color ?? Colors.white, - size: 24, + size: iconSize, ), ), ), diff --git a/lib/services/alerts/widgets/quick_swarm_report_modal.dart b/lib/services/alerts/widgets/quick_swarm_report_modal.dart index bf310c8..80a14c7 100644 --- a/lib/services/alerts/widgets/quick_swarm_report_modal.dart +++ b/lib/services/alerts/widgets/quick_swarm_report_modal.dart @@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState { @override 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( constraints: BoxConstraints(maxHeight: maxHeight), @@ -165,7 +171,12 @@ class _QuickSwarmReportModalState extends ConsumerState { ), child: SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + padding: EdgeInsets.fromLTRB( + 16, + isLandscape ? 8 : 12, + 16, + isLandscape ? 8 : 16, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -180,17 +191,17 @@ class _QuickSwarmReportModalState extends ConsumerState { ), ), ), - const SizedBox(height: 12), + SizedBox(height: isLandscape ? 6 : 12), Row( children: [ - const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24), + Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24), const SizedBox(width: 10), Expanded( child: Text( 'REPORT ROAD INCIDENT', style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 16, + fontSize: isLandscape ? 14 : 16, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.white, @@ -199,23 +210,27 @@ class _QuickSwarmReportModalState extends ConsumerState { ), IconButton( 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(), ), ], ), - const SizedBox(height: 4), - Text( - '1-tap broadcast to all nearby riders & pack members.', - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 12, - color: Colors.white70, + if (!isLandscape) ...[ + const SizedBox(height: 4), + Text( + '1-tap broadcast to all nearby riders & pack members.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: Colors.white70, + ), ), - ), + ], if (_isMovingFast) ...[ - const SizedBox(height: 10), + SizedBox(height: isLandscape ? 6 : 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), @@ -223,7 +238,7 @@ class _QuickSwarmReportModalState extends ConsumerState { ), child: const Row( children: [ - Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18), + Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16), SizedBox(width: 8), Expanded( child: Text( @@ -239,22 +254,23 @@ class _QuickSwarmReportModalState extends ConsumerState { ), ), ], - const SizedBox(height: 14), + SizedBox(height: isLandscape ? 8 : 14), Flexible( child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), child: GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _kReportCategories.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 2.2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: isLandscape ? 56.0 : 70.0, + crossAxisSpacing: 8, + mainAxisSpacing: 8, ), itemBuilder: (context, index) { final item = _kReportCategories[index]; - return _buildGridCard(item); + return _buildGridCard(item, isLandscape: isLandscape); }, ), ), @@ -266,14 +282,17 @@ class _QuickSwarmReportModalState extends ConsumerState { ); } - Widget _buildGridCard(_ReportItem item) { + Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) { return ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: item.color.withValues(alpha: 0.12), foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: isLandscape ? 6 : 10, + vertical: isLandscape ? 4 : 8, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5), ), elevation: 0, @@ -282,14 +301,14 @@ class _QuickSwarmReportModalState extends ConsumerState { child: Row( children: [ Container( - padding: const EdgeInsets.all(7), + padding: EdgeInsets.all(isLandscape ? 5 : 7), decoration: BoxDecoration( color: item.color.withValues(alpha: 0.22), 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( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -299,20 +318,22 @@ class _QuickSwarmReportModalState extends ConsumerState { item.title, style: TextStyle( fontFamily: AppTypography.fontHeadline, - fontSize: 13, + fontSize: isLandscape ? 11.5 : 13, fontWeight: FontWeight.bold, color: Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), - Text( - item.subtitle, - style: const TextStyle(fontSize: 10, color: Colors.white70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + if (!isLandscape) ...[ + const SizedBox(height: 2), + Text( + item.subtitle, + style: const TextStyle(fontSize: 10, color: Colors.white70), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ), diff --git a/test/canonical_history_records_garage_responsive_test.dart b/test/canonical_history_records_garage_responsive_test.dart index 3762ea5..458489d 100644 --- a/test/canonical_history_records_garage_responsive_test.dart +++ b/test/canonical_history_records_garage_responsive_test.dart @@ -317,6 +317,28 @@ void main() { 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)); + }); }); } diff --git a/test/dashboard_landscape_responsive_test.dart b/test/dashboard_landscape_responsive_test.dart index a50e1ed..1e344ae 100644 --- a/test/dashboard_landscape_responsive_test.dart +++ b/test/dashboard_landscape_responsive_test.dart @@ -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/location_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'; void main() { @@ -166,5 +168,81 @@ void main() { final edgeInsets = paddingWidget.padding as EdgeInsets; 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(); + }); }); } diff --git a/test/map_replay_duplicate_action_test.dart b/test/map_replay_duplicate_action_test.dart new file mode 100644 index 0000000..2feb2d3 --- /dev/null +++ b/test/map_replay_duplicate_action_test.dart @@ -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 getUrl(Uri url) async => _MockHttpRequest(); + + @override + Future 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 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> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.value(_transparent1x1Png).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +final _transparent1x1Png = [ + 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)); + }); +} diff --git a/test/performance_launch_screen_test.dart b/test/performance_launch_screen_test.dart index a73a863..341e66d 100644 --- a/test/performance_launch_screen_test.dart +++ b/test/performance_launch_screen_test.dart @@ -118,6 +118,22 @@ void main() { 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 { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); @@ -198,6 +214,21 @@ void main() { 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 { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); diff --git a/test/quick_swarm_report_modal_test.dart b/test/quick_swarm_report_modal_test.dart index f8c3cc9..ec7b6fd 100644 --- a/test/quick_swarm_report_modal_test.dart +++ b/test/quick_swarm_report_modal_test.dart @@ -329,5 +329,18 @@ void main() { expect(alerts.reports, isEmpty); 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); + }); } diff --git a/test/replay_screen_responsive_test.dart b/test/replay_screen_responsive_test.dart index 6b93077..55fcc38 100644 --- a/test/replay_screen_responsive_test.dart +++ b/test/replay_screen_responsive_test.dart @@ -165,4 +165,63 @@ void main() { expect(tester.takeException(), isNull); 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)); + }); }