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,
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,13 +254,18 @@ 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(
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,
@ -268,6 +274,7 @@ class HybridRidingActionBar extends ConsumerWidget {
),
),
),
),
);
}
}

View File

@ -138,37 +138,39 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: NavigationRail(
minWidth: 58,
groupAlignment: 0.0,
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: 21,
size: isShortRail ? 19 : 21,
),
unselectedIconTheme: IconThemeData(
color: colors.textMuted,
size: 19,
size: isShortRail ? 17 : 19,
),
selectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 10.0,
fontSize: isShortRail ? 9.0 : 10.0,
fontWeight: FontWeight.w700,
color: colors.brandCyan,
),
unselectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9.5,
fontSize: isShortRail ? 8.5 : 9.5,
fontWeight: FontWeight.w500,
color: colors.textMuted,
),
labelType: NavigationRailLabelType.all,
destinations: kAppNavDestinations.map((dest) {
return NavigationRailDestination(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
icon: Icon(dest.icon, key: dest.railKey),
selectedIcon: dest.selectedIcon != null
? Icon(dest.selectedIcon, key: dest.railKey)
@ -176,7 +178,8 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
label: Text(dest.label),
);
}).toList(),
),
);
}(),
),
),
),

View File

@ -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,13 +241,15 @@ 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: ClipRect(
child: CustomPaint(
size: Size(gaugeWidth, gaugeHeight),
painter: _MotorcycleLeanPainter(
@ -260,6 +263,7 @@ class LeanAngleIndicator extends ConsumerWidget {
),
),
),
),
SizedBox(height: compact ? 1 : 3),
@ -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);

View File

@ -326,18 +326,30 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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(
),
const Expanded(
child: Center(
child: SpeedReadout(size: SpeedReadoutSize.compact),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@ -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),

View File

@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
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<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 &&
ride.motorcycleMake != null &&
ride.motorcycleModel != null)
@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
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),

View File

@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
),
// 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,

View File

@ -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,36 +525,39 @@ class _WaitingView extends ConsumerWidget {
statusColor = colors.velocityBlue;
}
return Padding(
padding: const EdgeInsets.all(24),
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: 54, color: statusColor),
const SizedBox(height: 20),
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: 18,
fontSize: isLandscape ? 15 : 18,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: statusColor,
),
),
const SizedBox(height: 12),
SizedBox(height: isLandscape ? 6 : 12),
Text(
'Current speed: ${speed.toStringAsFixed(1)} MPH',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 14,
fontSize: isLandscape ? 12 : 14,
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 32),
SizedBox(height: isLandscape ? 16 : 32),
OutlinedButton(
key: const ValueKey('btn_cancel_waiting'),
onPressed: () {
@ -547,6 +567,7 @@ class _WaitingView extends ConsumerWidget {
),
],
),
),
);
}
}
@ -570,16 +591,25 @@ 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),
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: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: isLandscape ? 16 : 24,
vertical: isLandscape ? 4 : 8,
),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
@ -588,14 +618,14 @@ class _ReadyView extends ConsumerWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: colors.launchGreen, size: 28),
const SizedBox(width: 10),
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: 28,
fontSize: isLandscape ? 20 : 28,
fontWeight: FontWeight.w900,
letterSpacing: 2.0,
color: colors.launchGreen,
@ -605,18 +635,18 @@ class _ReadyView extends ConsumerWidget {
),
),
),
const SizedBox(height: 20),
SizedBox(height: isLandscape ? 8 : 20),
Text(
target.label,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 22,
fontSize: isLandscape ? 18 : 22,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 10),
SizedBox(height: isLandscape ? 4 : 10),
Text(
isCountdown
? 'Tap below to initiate countdown'
@ -624,15 +654,15 @@ class _ReadyView extends ConsumerWidget {
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 16,
fontSize: isLandscape ? 13 : 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black54,
),
),
const SizedBox(height: 40),
SizedBox(height: isLandscape ? 12 : 40),
if (isCountdown) ...[
SizedBox(
height: 56,
height: isLandscape ? 44 : 56,
child: ElevatedButton(
key: const ValueKey('btn_start_countdown'),
style: ElevatedButton.styleFrom(
@ -656,7 +686,7 @@ class _ReadyView extends ConsumerWidget {
),
),
),
const SizedBox(height: 16),
SizedBox(height: isLandscape ? 8 : 16),
],
Center(
child: TextButton(
@ -673,6 +703,7 @@ class _ReadyView extends ConsumerWidget {
),
],
),
),
);
}
}

View File

@ -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,12 +475,11 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
],
),
body: Column(
children: [
// Map View (with multi-color lean heatmap and telemetry HUD overlay)
Expanded(
flex: 5,
child: Stack(
body: ResponsiveLayout(
builder: (context, snapshot) {
final isLandscape = snapshot.isLandscape;
final mapWidget = Stack(
children: [
UnifiedMapView(
center: currentPos,
@ -495,8 +495,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
Positioned(
right: 14,
bottom: 14,
right: isLandscape ? 10 : 14,
bottom: isLandscape ? 10 : 14,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -507,21 +507,24 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
isCompact: isLandscape,
onTap: () => setState(() => _isFollowingRider = true),
),
const SizedBox(height: 8),
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)),
),
const SizedBox(height: 8),
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)),
),
],
@ -529,20 +532,20 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
// Telemetry Overlay
Positioned(
top: 14,
left: 14,
right: 14,
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'),
const SizedBox(width: 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
const SizedBox(width: 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
_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),
],
),
),
@ -550,9 +553,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
// Heatmap Legend Overlay
Positioned(
bottom: 10,
left: 14,
right: 14,
bottom: isLandscape ? 8 : 10,
left: isLandscape ? 8 : 14,
right: isLandscape ? 60 : 14,
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
@ -579,12 +582,13 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
),
],
),
),
);
// 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<ReplayScreen> {
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,13 +613,21 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
'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(
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,
@ -628,11 +640,15 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
: 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<ReplayScreen> {
});
},
),
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<ReplayScreen> {
'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<ReplayScreen> {
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<ReplayScreen> {
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<ReplayScreen> {
: '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<ReplayScreen> {
),
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,30 +798,36 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
});
},
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: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
children: [
// Corner ID & Direction
SizedBox(
width: 75,
width: isLandscape ? 65 : 75,
child: Row(
children: [
Text(
'T${corner.id}',
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: 13,
fontSize: isLandscape ? 12 : 13,
),
),
const SizedBox(width: 4),
Text(
corner.direction == 'Left' ? '↰ L' : '↱ R',
style: TextStyle(
fontSize: 11,
fontSize: isLandscape ? 10 : 11,
fontWeight: FontWeight.bold,
color: corner.direction == 'Left'
? Colors.blueAccent
@ -805,9 +838,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
],
),
),
const SizedBox(width: 8),
// Entry Speed
Expanded(
child: Column(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
),
Text(
'${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
Expanded(
child: Column(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
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<ReplayScreen> {
),
],
),
),
SizedBox(width: isLandscape ? 10 : 14),
// Exit Speed & Delta
Expanded(
child: Column(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@ -866,14 +900,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
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',
),
),
],
),
),
SizedBox(width: isLandscape ? 10 : 14),
// Grade Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
@ -895,15 +929,53 @@ 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(
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,
),
),
),

View File

@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
@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<QuickSwarmReportModal> {
),
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<QuickSwarmReportModal> {
),
),
),
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,10 +210,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
),
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(),
),
],
),
if (!isLandscape) ...[
const SizedBox(height: 4),
Text(
'1-tap broadcast to all nearby riders & pack members.',
@ -212,10 +226,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
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<QuickSwarmReportModal> {
),
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<QuickSwarmReportModal> {
),
),
],
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<QuickSwarmReportModal> {
);
}
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<QuickSwarmReportModal> {
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,13 +318,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
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,
),
if (!isLandscape) ...[
const SizedBox(height: 2),
Text(
item.subtitle,
@ -314,6 +334,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],

View File

@ -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));
});
});
}

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/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();
});
});
}

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);
});
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();

View File

@ -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);
});
}

View File

@ -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));
});
}