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:
parent
1fdca019fb
commit
3f64083bdd
|
|
@ -223,6 +223,8 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
finishLabel ?? 'Finish Ride',
|
finishLabel ?? 'Finish Ride',
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
@ -238,7 +240,6 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Ready / Standby state: Single prominent start button
|
// Ready / Standby state: Single prominent start button
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -253,13 +254,18 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
notifier.startRide();
|
notifier.startRide();
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.play_arrow_rounded, size: 26, color: colors.brandCyan),
|
icon: Icon(Icons.play_arrow_rounded, size: compact ? 22 : 26, color: colors.brandCyan),
|
||||||
label: const Text(
|
label: const FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
'Start Session',
|
'Start Session',
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
|
|
@ -268,6 +274,7 @@ class HybridRidingActionBar extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,37 +138,39 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
|
||||||
minHeight: constraints.maxHeight,
|
minHeight: constraints.maxHeight,
|
||||||
),
|
),
|
||||||
child: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: NavigationRail(
|
child: () {
|
||||||
minWidth: 58,
|
final isShortRail = constraints.maxHeight < 400;
|
||||||
groupAlignment: 0.0,
|
return NavigationRail(
|
||||||
|
minWidth: isShortRail ? 52 : 58,
|
||||||
|
groupAlignment: isShortRail ? -1.0 : 0.0,
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onDestinationSelected: _onDestinationSelected,
|
onDestinationSelected: _onDestinationSelected,
|
||||||
backgroundColor: colors.surfaceCanvas,
|
backgroundColor: colors.surfaceCanvas,
|
||||||
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
indicatorColor: colors.brandCyan.withValues(alpha: 0.14),
|
||||||
selectedIconTheme: IconThemeData(
|
selectedIconTheme: IconThemeData(
|
||||||
color: colors.brandCyan,
|
color: colors.brandCyan,
|
||||||
size: 21,
|
size: isShortRail ? 19 : 21,
|
||||||
),
|
),
|
||||||
unselectedIconTheme: IconThemeData(
|
unselectedIconTheme: IconThemeData(
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
size: 19,
|
size: isShortRail ? 17 : 19,
|
||||||
),
|
),
|
||||||
selectedLabelTextStyle: TextStyle(
|
selectedLabelTextStyle: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 10.0,
|
fontSize: isShortRail ? 9.0 : 10.0,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: colors.brandCyan,
|
color: colors.brandCyan,
|
||||||
),
|
),
|
||||||
unselectedLabelTextStyle: TextStyle(
|
unselectedLabelTextStyle: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 9.5,
|
fontSize: isShortRail ? 8.5 : 9.5,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
),
|
),
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: kAppNavDestinations.map((dest) {
|
destinations: kAppNavDestinations.map((dest) {
|
||||||
return NavigationRailDestination(
|
return NavigationRailDestination(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: EdgeInsets.symmetric(vertical: isShortRail ? 1 : 4),
|
||||||
icon: Icon(dest.icon, key: dest.railKey),
|
icon: Icon(dest.icon, key: dest.railKey),
|
||||||
selectedIcon: dest.selectedIcon != null
|
selectedIcon: dest.selectedIcon != null
|
||||||
? Icon(dest.selectedIcon, key: dest.railKey)
|
? Icon(dest.selectedIcon, key: dest.railKey)
|
||||||
|
|
@ -176,7 +178,8 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
|
||||||
label: Text(dest.label),
|
label: Text(dest.label),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
);
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
// Header
|
// Header
|
||||||
Text(
|
Text(
|
||||||
'LEAN ANGLE',
|
'LEAN ANGLE',
|
||||||
|
key: const ValueKey('lean_angle_title'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: onSurfaceVariant,
|
color: onSurfaceVariant,
|
||||||
fontSize: compact ? 9 : 11,
|
fontSize: compact ? 9 : 11,
|
||||||
|
|
@ -240,13 +241,15 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 1 : 3),
|
SizedBox(height: compact ? 3 : 6),
|
||||||
|
|
||||||
// Motorcycle Leaning Silhouette with Stable Horizon
|
// Motorcycle Leaning Silhouette with Stable Horizon
|
||||||
RepaintBoundary(
|
RepaintBoundary(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
key: const ValueKey('lean_angle_rider_graphic'),
|
||||||
width: gaugeWidth,
|
width: gaugeWidth,
|
||||||
height: gaugeHeight,
|
height: gaugeHeight,
|
||||||
|
child: ClipRect(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
size: Size(gaugeWidth, gaugeHeight),
|
size: Size(gaugeWidth, gaugeHeight),
|
||||||
painter: _MotorcycleLeanPainter(
|
painter: _MotorcycleLeanPainter(
|
||||||
|
|
@ -260,6 +263,7 @@ class LeanAngleIndicator extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
SizedBox(height: compact ? 1 : 3),
|
SizedBox(height: compact ? 1 : 3),
|
||||||
|
|
||||||
|
|
@ -426,6 +430,10 @@ class _MotorcycleLeanPainter extends CustomPainter {
|
||||||
// 2. Draw Leaning Motorcycle Silhouette
|
// 2. Draw Leaning Motorcycle Silhouette
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.translate(contactPoint.dx, contactPoint.dy);
|
canvas.translate(contactPoint.dx, contactPoint.dy);
|
||||||
|
// Scale silhouette to fit comfortably below the top edge of the gauge
|
||||||
|
final availableTopSpace = horizonY - 4.0;
|
||||||
|
final double scale = (availableTopSpace / 47.5).clamp(0.5, 0.95);
|
||||||
|
canvas.scale(scale);
|
||||||
final clampedLean = leanAngle.clamp(-60.0, 60.0);
|
final clampedLean = leanAngle.clamp(-60.0, 60.0);
|
||||||
canvas.rotate(clampedLean * math.pi / 180.0);
|
canvas.rotate(clampedLean * math.pi / 180.0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -326,18 +326,30 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Row(
|
const FittedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
DashboardLayoutSwitcher(),
|
DashboardLayoutSwitcher(),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
QuickSwarmReportButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
QuickSquadFeedButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
GPSStatusIndicator(showLabel: false),
|
GPSStatusIndicator(showLabel: false),
|
||||||
|
SizedBox(width: 4),
|
||||||
HeadingIndicator(compact: true),
|
HeadingIndicator(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
RideStatusChip(),
|
RideStatusChip(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Center(
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Center(
|
||||||
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
child: SpeedReadout(size: SpeedReadoutSize.compact),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -386,7 +398,7 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
|
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 140,
|
width: 164,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -463,6 +475,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
DashboardLayoutSwitcher(),
|
DashboardLayoutSwitcher(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
|
QuickSwarmReportButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
QuickSquadFeedButton(compact: true),
|
||||||
|
SizedBox(width: 4),
|
||||||
GPSStatusIndicator(),
|
GPSStatusIndicator(),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
HeadingIndicator(compact: true),
|
HeadingIndicator(compact: true),
|
||||||
|
|
@ -472,7 +488,9 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const NavigationManeuverCard(compact: true),
|
const NavigationManeuverCard(compact: true),
|
||||||
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
const Expanded(
|
||||||
|
child: Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
|
||||||
|
),
|
||||||
const RideActionButton(compact: true),
|
const RideActionButton(compact: true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const PerformanceLaunchShortcut(compact: true),
|
const PerformanceLaunchShortcut(compact: true),
|
||||||
|
|
|
||||||
|
|
@ -126,13 +126,13 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 600,
|
maxCrossAxisExtent: 600,
|
||||||
mainAxisExtent: 175,
|
mainAxisExtent: 196,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
),
|
),
|
||||||
itemCount: rides.length,
|
itemCount: rides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _buildRideCard(context, rides[index], colors);
|
return _buildRideCard(context, rides[index], colors, isGrid: true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,12 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) {
|
Widget _buildRideCard(
|
||||||
|
BuildContext context,
|
||||||
|
Ride ride,
|
||||||
|
MotorSignalThemeColors colors, {
|
||||||
|
bool isGrid = false,
|
||||||
|
}) {
|
||||||
final bikeName = (ride.motorcycleYear != null &&
|
final bikeName = (ride.motorcycleYear != null &&
|
||||||
ride.motorcycleMake != null &&
|
ride.motorcycleMake != null &&
|
||||||
ride.motorcycleModel != null)
|
ride.motorcycleModel != null)
|
||||||
|
|
@ -169,7 +174,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
margin: isGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(color: colors.outlineVariant),
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
|
|
||||||
|
|
@ -995,8 +995,8 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom Center: FIRE OFF REPLAY floating button
|
// Bottom Center: FIRE OFF REPLAY floating button (portrait only; in landscape play_replay_appbar_button is used)
|
||||||
if (_routePoints.isNotEmpty)
|
if (_routePoints.isNotEmpty && !responsive.isLandscape)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
left: 20,
|
left: 20,
|
||||||
|
|
|
||||||
|
|
@ -182,21 +182,23 @@ class _SetupView extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isShortLandscape = snapshot.isLandscape && snapshot.isShortViewport;
|
final isLandscape = snapshot.isLandscape;
|
||||||
|
|
||||||
if (isShortLandscape) {
|
if (isLandscape) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildModeSelector(context),
|
_buildModeSelector(context, isLandscape: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
_buildTargetSelector(context),
|
_buildTargetSelector(context, isLandscape: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -204,12 +206,13 @@ class _SetupView extends StatelessWidget {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildExplanationCard(context),
|
_buildExplanationCard(context, isLandscape: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
_buildArmButton(context),
|
_buildArmButton(context, isLandscape: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -236,7 +239,7 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildModeSelector(BuildContext context) {
|
Widget _buildModeSelector(BuildContext context, {bool isLandscape = false}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -244,13 +247,13 @@ class _SetupView extends StatelessWidget {
|
||||||
'START MODE',
|
'START MODE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -263,6 +266,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onModeChanged(PerformanceLaunchMode.auto),
|
onTap: () => onModeChanged(PerformanceLaunchMode.auto),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -276,6 +280,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
|
onTap: () => onModeChanged(PerformanceLaunchMode.countdown),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -284,7 +289,7 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTargetSelector(BuildContext context) {
|
Widget _buildTargetSelector(BuildContext context, {bool isLandscape = false}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -292,13 +297,13 @@ class _SetupView extends StatelessWidget {
|
||||||
'BENCHMARK TARGET',
|
'BENCHMARK TARGET',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -311,6 +316,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
|
onTap: () => onTargetChanged(PerformanceLaunchTarget.sixty),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -324,6 +330,7 @@ class _SetupView extends StatelessWidget {
|
||||||
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
|
onTap: () => onTargetChanged(PerformanceLaunchTarget.hundred),
|
||||||
colors: colors,
|
colors: colors,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
isLandscape: isLandscape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -332,13 +339,13 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExplanationCard(BuildContext context) {
|
Widget _buildExplanationCard(BuildContext context, {bool isLandscape = false}) {
|
||||||
final text = selectedMode == PerformanceLaunchMode.auto
|
final text = selectedMode == PerformanceLaunchMode.auto
|
||||||
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
|
? 'Auto Launch:\nTiming begins immediately when MotorSignal detects validated motorcycle movement (≥ 1.0 MPH).'
|
||||||
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
|
: 'Countdown:\nMonotonic 3 → 2 → 1 → GO cadence. Launch Reaction is measured from the scheduled GO instant.';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: EdgeInsets.all(isLandscape ? 10 : 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -349,17 +356,17 @@ class _SetupView extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.info_outline,
|
Icons.info_outline,
|
||||||
size: 20,
|
size: isLandscape ? 18 : 20,
|
||||||
color: colors.velocityBlue,
|
color: colors.velocityBlue,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
SizedBox(width: isLandscape ? 8 : 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: isLandscape ? 11.5 : 13,
|
||||||
height: 1.35,
|
height: 1.3,
|
||||||
color: isDark ? Colors.white70 : Colors.black87,
|
color: isDark ? Colors.white70 : Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -369,15 +376,16 @@ class _SetupView extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArmButton(BuildContext context) {
|
Widget _buildArmButton(BuildContext context, {bool isLandscape = false}) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 52,
|
height: isLandscape ? 44 : 52,
|
||||||
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
key: const ValueKey('btn_arm_launch'),
|
key: const ValueKey('btn_arm_launch'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.launchGreen,
|
backgroundColor: colors.launchGreen,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
),
|
),
|
||||||
onPressed: onArm,
|
onPressed: onArm,
|
||||||
|
|
@ -385,7 +393,7 @@ class _SetupView extends StatelessWidget {
|
||||||
'Arm Launch',
|
'Arm Launch',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
|
|
@ -403,6 +411,7 @@ class _SelectionButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final MotorSignalThemeColors colors;
|
final MotorSignalThemeColors colors;
|
||||||
final bool isDark;
|
final bool isDark;
|
||||||
|
final bool isLandscape;
|
||||||
|
|
||||||
const _SelectionButton({
|
const _SelectionButton({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -413,6 +422,7 @@ class _SelectionButton extends StatelessWidget {
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.colors,
|
required this.colors,
|
||||||
required this.isDark,
|
required this.isDark,
|
||||||
|
this.isLandscape = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -428,7 +438,10 @@ class _SelectionButton extends StatelessWidget {
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 8 : 12,
|
||||||
|
vertical: isLandscape ? 8 : 12,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bgColor,
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -439,27 +452,31 @@ class _SelectionButton extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 22,
|
size: isLandscape ? 18 : 22,
|
||||||
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
|
color: isSelected ? colors.launchGreen : (isDark ? Colors.white70 : Colors.black54),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 4 : 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 15,
|
fontSize: isLandscape ? 13 : 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
color: isDark ? Colors.white60 : Colors.black54,
|
color: isDark ? Colors.white60 : Colors.black54,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -508,36 +525,39 @@ class _WaitingView extends ConsumerWidget {
|
||||||
statusColor = colors.velocityBlue;
|
statusColor = colors.velocityBlue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
final isLandscape = snapshot.isLandscape;
|
||||||
padding: const EdgeInsets.all(24),
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
padding: EdgeInsets.all(isLandscape ? 16 : 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(statusIcon, size: 54, color: statusColor),
|
Icon(statusIcon, size: isLandscape ? 36 : 54, color: statusColor),
|
||||||
const SizedBox(height: 20),
|
SizedBox(height: isLandscape ? 10 : 20),
|
||||||
Text(
|
Text(
|
||||||
statusText,
|
statusText,
|
||||||
key: const ValueKey('waiting_status_text'),
|
key: const ValueKey('waiting_status_text'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 18,
|
fontSize: isLandscape ? 15 : 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
color: statusColor,
|
color: statusColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: isLandscape ? 6 : 12),
|
||||||
Text(
|
Text(
|
||||||
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
'Current speed: ${speed.toStringAsFixed(1)} MPH',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontTelemetry,
|
fontFamily: AppTypography.fontTelemetry,
|
||||||
fontSize: 14,
|
fontSize: isLandscape ? 12 : 14,
|
||||||
color: isDark ? Colors.white70 : Colors.black54,
|
color: isDark ? Colors.white70 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: isLandscape ? 16 : 32),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
key: const ValueKey('btn_cancel_waiting'),
|
key: const ValueKey('btn_cancel_waiting'),
|
||||||
onPressed: () {
|
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 mode = ref.watch(performanceLaunchProvider.select((s) => s.mode));
|
||||||
final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
|
final target = ref.watch(performanceLaunchProvider.select((s) => s.target));
|
||||||
final isCountdown = mode == PerformanceLaunchMode.countdown;
|
final isCountdown = mode == PerformanceLaunchMode.countdown;
|
||||||
|
final isLandscape = snapshot.isLandscape;
|
||||||
|
|
||||||
return Padding(
|
return Center(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 32 : 20,
|
||||||
|
vertical: isLandscape ? 8 : 16,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 16 : 24,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.launchGreen.withValues(alpha: 0.15),
|
color: colors.launchGreen.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
|
@ -588,14 +618,14 @@ class _ReadyView extends ConsumerWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check_circle, color: colors.launchGreen, size: 28),
|
Icon(Icons.check_circle, color: colors.launchGreen, size: isLandscape ? 20 : 28),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'READY',
|
'READY',
|
||||||
key: const ValueKey('ready_status_badge'),
|
key: const ValueKey('ready_status_badge'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 28,
|
fontSize: isLandscape ? 20 : 28,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
letterSpacing: 2.0,
|
letterSpacing: 2.0,
|
||||||
color: colors.launchGreen,
|
color: colors.launchGreen,
|
||||||
|
|
@ -605,18 +635,18 @@ class _ReadyView extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
SizedBox(height: isLandscape ? 8 : 20),
|
||||||
Text(
|
Text(
|
||||||
target.label,
|
target.label,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 22,
|
fontSize: isLandscape ? 18 : 22,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isLandscape ? 4 : 10),
|
||||||
Text(
|
Text(
|
||||||
isCountdown
|
isCountdown
|
||||||
? 'Tap below to initiate countdown'
|
? 'Tap below to initiate countdown'
|
||||||
|
|
@ -624,15 +654,15 @@ class _ReadyView extends ConsumerWidget {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 16,
|
fontSize: isLandscape ? 13 : 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isDark ? Colors.white70 : Colors.black54,
|
color: isDark ? Colors.white70 : Colors.black54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
SizedBox(height: isLandscape ? 12 : 40),
|
||||||
if (isCountdown) ...[
|
if (isCountdown) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 56,
|
height: isLandscape ? 44 : 56,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
key: const ValueKey('btn_start_countdown'),
|
key: const ValueKey('btn_start_countdown'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|
@ -656,7 +686,7 @@ class _ReadyView extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: isLandscape ? 8 : 16),
|
||||||
],
|
],
|
||||||
Center(
|
Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
|
|
@ -673,6 +703,7 @@ class _ReadyView extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../database/database.dart';
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
import '../../services/map/unified_map_view.dart';
|
import '../../services/map/unified_map_view.dart';
|
||||||
import '../../core/theme/theme_extensions.dart';
|
import '../../core/theme/theme_extensions.dart';
|
||||||
|
import '../../core/responsive/responsive_layout.dart';
|
||||||
|
|
||||||
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
||||||
class CornerAnalysis {
|
class CornerAnalysis {
|
||||||
|
|
@ -474,12 +475,11 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: ResponsiveLayout(
|
||||||
children: [
|
builder: (context, snapshot) {
|
||||||
// Map View (with multi-color lean heatmap and telemetry HUD overlay)
|
final isLandscape = snapshot.isLandscape;
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
final mapWidget = Stack(
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
UnifiedMapView(
|
UnifiedMapView(
|
||||||
center: currentPos,
|
center: currentPos,
|
||||||
|
|
@ -495,8 +495,8 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 14,
|
right: isLandscape ? 10 : 14,
|
||||||
bottom: 14,
|
bottom: isLandscape ? 10 : 14,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -507,21 +507,24 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
tooltip: 'Recenter on Rider',
|
tooltip: 'Recenter on Rider',
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
|
||||||
|
isCompact: isLandscape,
|
||||||
onTap: () => setState(() => _isFollowingRider = true),
|
onTap: () => setState(() => _isFollowingRider = true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
],
|
],
|
||||||
_ReplayMapButton(
|
_ReplayMapButton(
|
||||||
key: const ValueKey('replay_zoom_in_button'),
|
key: const ValueKey('replay_zoom_in_button'),
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
tooltip: 'Zoom In',
|
tooltip: 'Zoom In',
|
||||||
|
isCompact: isLandscape,
|
||||||
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: isLandscape ? 6 : 8),
|
||||||
_ReplayMapButton(
|
_ReplayMapButton(
|
||||||
key: const ValueKey('replay_zoom_out_button'),
|
key: const ValueKey('replay_zoom_out_button'),
|
||||||
icon: Icons.remove,
|
icon: Icons.remove,
|
||||||
tooltip: 'Zoom Out',
|
tooltip: 'Zoom Out',
|
||||||
|
isCompact: isLandscape,
|
||||||
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -529,20 +532,20 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
// Telemetry Overlay
|
// Telemetry Overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 14,
|
top: isLandscape ? 8 : 14,
|
||||||
left: 14,
|
left: isLandscape ? 8 : 14,
|
||||||
right: 14,
|
right: isLandscape ? 8 : 14,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
|
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph', isLandscape: isLandscape),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
|
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°', isLandscape: isLandscape),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
|
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G', isLandscape: isLandscape),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -550,9 +553,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
// Heatmap Legend Overlay
|
// Heatmap Legend Overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 10,
|
bottom: isLandscape ? 8 : 10,
|
||||||
left: 14,
|
left: isLandscape ? 8 : 14,
|
||||||
right: 14,
|
right: isLandscape ? 60 : 14,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
|
|
@ -579,12 +582,13 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
|
||||||
|
|
||||||
// Playback Controls Bar
|
final playbackControlsBar = Container(
|
||||||
Container(
|
padding: EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
horizontal: isLandscape ? 12 : 16,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -596,10 +600,10 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
child: Text(
|
child: Text(
|
||||||
currentSample.timestamp.toLocal().toString().split('.')[0],
|
currentSample.timestamp.toLocal().toString().split('.')[0],
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.cyanAccent,
|
color: Colors.cyanAccent,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -609,13 +613,21 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Slider(
|
SliderTheme(
|
||||||
|
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,
|
value: currentSliderValue,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: maxSliderValue,
|
max: maxSliderValue,
|
||||||
|
|
@ -628,11 +640,15 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
: null,
|
: null,
|
||||||
activeColor: Colors.cyanAccent,
|
activeColor: Colors.cyanAccent,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restart_alt),
|
visualDensity: isLandscape ? VisualDensity.compact : null,
|
||||||
|
padding: isLandscape ? const EdgeInsets.all(4) : null,
|
||||||
|
constraints: isLandscape ? const BoxConstraints() : null,
|
||||||
|
icon: Icon(Icons.restart_alt, size: isLandscape ? 20 : 24),
|
||||||
tooltip: 'Restart',
|
tooltip: 'Restart',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -640,35 +656,46 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
SizedBox(width: isLandscape ? 8 : 16),
|
||||||
FloatingActionButton.small(
|
FloatingActionButton.small(
|
||||||
onPressed: _togglePlayback,
|
onPressed: _togglePlayback,
|
||||||
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
|
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: isLandscape ? 18 : 20),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
SizedBox(width: isLandscape ? 8 : 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
style: isLandscape
|
||||||
|
? TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onPressed: _changeSpeed,
|
onPressed: _changeSpeed,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_playbackSpeed}x',
|
'${_playbackSpeed}x',
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'JetBrains Mono'),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
fontSize: isLandscape ? 12 : 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
|
||||||
// Corner Entry / Apex / Exit Breakdown Table
|
Widget buildCornerTable() {
|
||||||
Expanded(
|
return Container(
|
||||||
flex: _corners.isEmpty ? 3 : 4,
|
|
||||||
child: Container(
|
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 14,
|
||||||
|
vertical: isLandscape ? 6 : 8,
|
||||||
|
),
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
@ -678,7 +705,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
'CORNER APEX BREAKDOWN & GRADING',
|
'CORNER APEX BREAKDOWN & GRADING',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
|
|
@ -690,7 +717,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
_corners.isEmpty ? '' : 'Tap row to seek apex',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: isLandscape ? 9 : 10,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
@ -712,7 +739,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
hasLeanData
|
hasLeanData
|
||||||
? Icons.straighten_rounded
|
? Icons.straighten_rounded
|
||||||
: Icons.sensors_off_rounded,
|
: Icons.sensors_off_rounded,
|
||||||
size: 26,
|
size: isLandscape ? 22 : 26,
|
||||||
color: hasLeanData
|
color: hasLeanData
|
||||||
? colors.cyberCyan.withValues(alpha: 0.7)
|
? colors.cyberCyan.withValues(alpha: 0.7)
|
||||||
: colors.warningAmber.withValues(alpha: 0.8),
|
: colors.warningAmber.withValues(alpha: 0.8),
|
||||||
|
|
@ -724,7 +751,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
|
|
@ -732,14 +759,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
constraints: BoxConstraints(maxWidth: isLandscape ? 320 : 280),
|
||||||
child: Text(
|
child: Text(
|
||||||
hasLeanData
|
hasLeanData
|
||||||
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
|
||||||
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
),
|
),
|
||||||
|
|
@ -771,30 +798,36 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 14,
|
||||||
|
vertical: isLandscape ? 6 : 9,
|
||||||
|
),
|
||||||
color: isCurrent
|
color: isCurrent
|
||||||
? colors.cyberCyan.withValues(alpha: 0.12)
|
? colors.cyberCyan.withValues(alpha: 0.12)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Corner ID & Direction
|
// Corner ID & Direction
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 75,
|
width: isLandscape ? 65 : 75,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'T${corner.id}',
|
'T${corner.id}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
fontSize: 13,
|
fontSize: isLandscape ? 12 : 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: isLandscape ? 10 : 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: corner.direction == 'Left'
|
color: corner.direction == 'Left'
|
||||||
? Colors.blueAccent
|
? Colors.blueAccent
|
||||||
|
|
@ -805,9 +838,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
// Entry Speed
|
// Entry Speed
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -820,14 +853,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
||||||
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'),
|
style: TextStyle(
|
||||||
|
fontSize: isLandscape ? 11 : 12,
|
||||||
|
fontFamily: 'JetBrains Mono',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Apex Speed & Lean
|
// Apex Speed & Lean
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -841,7 +876,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
|
|
@ -849,10 +884,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Exit Speed & Delta
|
// Exit Speed & Delta
|
||||||
Expanded(
|
Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -866,14 +900,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Text(
|
Text(
|
||||||
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: isLandscape ? 11 : 12,
|
||||||
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: isLandscape ? 10 : 14),
|
||||||
// Grade Badge
|
// Grade Badge
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
|
@ -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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 10 : 16,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(isLandscape ? 8 : 12),
|
||||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)),
|
Text(label, style: TextStyle(fontSize: isLandscape ? 8.5 : 10, color: Colors.white70)),
|
||||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)),
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: isLandscape ? 15 : 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.cyanAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -950,6 +1032,7 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
const _ReplayMapButton({
|
const _ReplayMapButton({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -958,10 +1041,13 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.color,
|
this.color,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
|
this.isCompact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final size = isCompact ? 36.0 : 48.0;
|
||||||
|
final iconSize = isCompact ? 18.0 : 24.0;
|
||||||
return Material(
|
return Material(
|
||||||
color: backgroundColor ?? Colors.black87,
|
color: backgroundColor ?? Colors.black87,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -974,12 +1060,12 @@ class _ReplayMapButton extends StatelessWidget {
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 48,
|
width: size,
|
||||||
height: 48,
|
height: size,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
color: color ?? Colors.white,
|
color: color ?? Colors.white,
|
||||||
size: 24,
|
size: iconSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final insets = MediaQuery.viewInsetsOf(context);
|
||||||
|
final isLandscape = size.width > size.height;
|
||||||
|
final maxHeight = (size.height - insets.bottom) * (isLandscape ? 0.95 : 0.85);
|
||||||
|
final crossAxisCount = size.width >= 720
|
||||||
|
? 4
|
||||||
|
: (size.width >= 480 ? 3 : 2);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
|
|
@ -165,7 +171,12 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
16,
|
||||||
|
isLandscape ? 8 : 16,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -180,17 +191,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: isLandscape ? 6 : 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.campaign, color: AppColors.cyberCyan, size: 24),
|
Icon(Icons.campaign, color: AppColors.cyberCyan, size: isLandscape ? 20 : 24),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'REPORT ROAD INCIDENT',
|
'REPORT ROAD INCIDENT',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: isLandscape ? 14 : 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|
@ -199,10 +210,13 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, color: Colors.white60),
|
icon: const Icon(Icons.close, color: Colors.white60),
|
||||||
|
padding: isLandscape ? EdgeInsets.zero : const EdgeInsets.all(8.0),
|
||||||
|
constraints: isLandscape ? const BoxConstraints() : null,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (!isLandscape) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'1-tap broadcast to all nearby riders & pack members.',
|
'1-tap broadcast to all nearby riders & pack members.',
|
||||||
|
|
@ -212,10 +226,11 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
if (_isMovingFast) ...[
|
if (_isMovingFast) ...[
|
||||||
const SizedBox(height: 10),
|
SizedBox(height: isLandscape ? 6 : 10),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.amber.withValues(alpha: 0.15),
|
color: Colors.amber.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
|
@ -223,7 +238,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 18),
|
Icon(Icons.warning_amber_rounded, color: Colors.amber, size: 16),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -239,22 +254,23 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
SizedBox(height: isLandscape ? 8 : 14),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: _kReportCategories.length,
|
itemCount: _kReportCategories.length,
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: crossAxisCount,
|
||||||
childAspectRatio: 2.2,
|
mainAxisExtent: isLandscape ? 56.0 : 70.0,
|
||||||
crossAxisSpacing: 10,
|
crossAxisSpacing: 8,
|
||||||
mainAxisSpacing: 10,
|
mainAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _kReportCategories[index];
|
final item = _kReportCategories[index];
|
||||||
return _buildGridCard(item);
|
return _buildGridCard(item, isLandscape: isLandscape);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -266,14 +282,17 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridCard(_ReportItem item) {
|
Widget _buildGridCard(_ReportItem item, {bool isLandscape = false}) {
|
||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: item.color.withValues(alpha: 0.12),
|
backgroundColor: item.color.withValues(alpha: 0.12),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isLandscape ? 6 : 10,
|
||||||
|
vertical: isLandscape ? 4 : 8,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
side: BorderSide(color: item.color.withValues(alpha: 0.7), width: 1.5),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|
@ -282,14 +301,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(7),
|
padding: EdgeInsets.all(isLandscape ? 5 : 7),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: item.color.withValues(alpha: 0.22),
|
color: item.color.withValues(alpha: 0.22),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(item.icon, color: item.color, size: 22),
|
child: Icon(item.icon, color: item.color, size: isLandscape ? 18 : 22),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: isLandscape ? 6 : 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -299,13 +318,14 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
item.title,
|
item.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 13,
|
fontSize: isLandscape ? 11.5 : 13,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
if (!isLandscape) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
item.subtitle,
|
item.subtitle,
|
||||||
|
|
@ -314,6 +334,7 @@ class _QuickSwarmReportModalState extends ConsumerState<QuickSwarmReportModal> {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,28 @@ void main() {
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets('BUG-012: HistoryScreen ride cards show all bottom row telemetry without overflow on 800x360 landscape', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestApp(const HistoryScreen()));
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('history_ride_card_1')), findsOneWidget);
|
||||||
|
expect(find.text('TRIP'), findsOneWidget);
|
||||||
|
expect(find.text('TIME'), findsOneWidget);
|
||||||
|
expect(find.text('TOP SPD'), findsOneWidget);
|
||||||
|
expect(find.text('MAX LEAN'), findsOneWidget);
|
||||||
|
expect(find.text('HARD BRK'), findsOneWidget);
|
||||||
|
expect(find.text('ELEV GAIN'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||||
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
||||||
import 'package:motorcycle_dash/services/location_service.dart';
|
import 'package:motorcycle_dash/services/location_service.dart';
|
||||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||||
|
import 'package:motorcycle_dash/features/social/feed/widgets/quick_squad_feed_button.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -166,5 +168,81 @@ void main() {
|
||||||
final edgeInsets = paddingWidget.padding as EdgeInsets;
|
final edgeInsets = paddingWidget.padding as EdgeInsets;
|
||||||
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
|
expect(edgeInsets.right, greaterThanOrEqualTo(16.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007: Short landscape cockpit mount includes incident report and squad buttons with balanced layout', (tester) async {
|
||||||
|
configureViewport(tester, 800, 360);
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byType(QuickSwarmReportButton), findsOneWidget);
|
||||||
|
expect(find.byType(QuickSquadFeedButton), findsOneWidget);
|
||||||
|
expect(find.byType(SpeedReadout), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: Start Session button text is fully visible without clipping in minimal landscape (map hidden)', (tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'minimal');
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(840, 390)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
final startBtnText = find.text('Start Session');
|
||||||
|
expect(startBtnText, findsOneWidget);
|
||||||
|
|
||||||
|
// Verify button text has positive width and fits cleanly
|
||||||
|
final textRenderBox = tester.renderObject(startBtnText) as RenderBox;
|
||||||
|
expect(textRenderBox.size.width, greaterThan(0));
|
||||||
|
expect(textRenderBox.size.height, greaterThan(0));
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: Start Session button text is fully visible in sport and touring landscape layouts', (tester) async {
|
||||||
|
for (final layout in ['sport', 'touring']) {
|
||||||
|
await prefs.setString('dashboardLayout', layout);
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('Start Session'), findsOneWidget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-007 / #54: LEAN ANGLE title does not overlap rider graphic across landscape viewports', (tester) async {
|
||||||
|
await prefs.setString('dashboardLayout', 'sport');
|
||||||
|
for (final size in [const Size(640, 360), const Size(800, 360), const Size(2000, 900)]) {
|
||||||
|
configureViewport(tester, size.width, size.height);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildTestDashboard());
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
final titleFinder = find.text('LEAN ANGLE');
|
||||||
|
final graphicFinder = find.byKey(const ValueKey('lean_angle_rider_graphic'));
|
||||||
|
|
||||||
|
expect(titleFinder, findsOneWidget);
|
||||||
|
expect(graphicFinder, findsOneWidget);
|
||||||
|
|
||||||
|
final titleRect = tester.getRect(titleFinder);
|
||||||
|
final graphicRect = tester.getRect(graphicFinder);
|
||||||
|
|
||||||
|
// Prove no overlap between title rect and rider graphic rect
|
||||||
|
expect(titleRect.overlaps(graphicRect), isFalse, reason: 'Title overlaps rider graphic at ${size.width}x${size.height}');
|
||||||
|
// Prove title is positioned strictly above graphic
|
||||||
|
expect(titleRect.bottom, lessThanOrEqualTo(graphicRect.top), reason: 'Title bottom must be above or at graphic top at ${size.width}x${size.height}');
|
||||||
|
}
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,22 @@ void main() {
|
||||||
expect(find.text('0–100 MPH'), findsOneWidget);
|
expect(find.text('0–100 MPH'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-009: Renders cleanly without clipping on 800x360 landscape phone', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||||||
|
expect(find.text('Auto Launch'), findsOneWidget);
|
||||||
|
expect(find.text('Countdown'), findsOneWidget);
|
||||||
|
expect(find.text('0–60 MPH'), findsOneWidget);
|
||||||
|
expect(find.text('0–100 MPH'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
|
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -198,6 +214,21 @@ void main() {
|
||||||
expect(find.text('Tap below to initiate countdown'), findsOneWidget);
|
expect(find.text('Tap below to initiate countdown'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-010: Countdown READY renders without 68px overflow on 800x360 landscape phone', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||||||
|
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
|
||||||
|
expect(find.text('Start Countdown'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('8. READY survives widget rebuild', (tester) async {
|
testWidgets('8. READY survives widget rebuild', (tester) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,18 @@ void main() {
|
||||||
expect(alerts.reports, isEmpty);
|
expect(alerts.reports, isEmpty);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-008: renders cleanly without overflow on 800x360 landscape phone', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await open(tester);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('REPORT ROAD INCIDENT'), findsOneWidget);
|
||||||
|
expect(find.text('Police'), findsOneWidget);
|
||||||
|
expect(find.text('Road Hazard'), findsOneWidget);
|
||||||
|
expect(find.text('Weather Hazard'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,4 +165,63 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-014 (#61): ReplayScreen renders responsive horizontal split in 800x360 landscape without overflow', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 360);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ReplayScreen(rideId: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
// Verifies both map and breakdown table are rendered concurrently in viewport
|
||||||
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
|
expect(find.text('SPEED'), findsOneWidget);
|
||||||
|
expect(find.text('LEAN'), findsOneWidget);
|
||||||
|
expect(find.text('G'), findsOneWidget);
|
||||||
|
expect(find.byType(Slider), findsOneWidget);
|
||||||
|
expect(find.byType(Row), findsWidgets);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('BUG-014 (#61): ReplayScreen renders without overflow on 640x320 compact landscape', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(640, 320);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ReplayScreen(rideId: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('CORNER APEX BREAKDOWN & GRADING'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue