MotorSignal-App/lib/features/dashboard/widgets/responsive_dashboard_layout...

927 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/responsive/viewport_size.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../performance/models/performance_launch_models.dart';
import '../../performance/performance_providers.dart';
import '../../rides/ride_state_machine.dart';
import '../dashboard_config.dart';
import 'dashboard_layout_switcher.dart';
import 'dashboard_map_panel.dart';
import 'g_force_indicator.dart';
import 'gps_status_indicator.dart';
import 'heading_indicator.dart';
import 'lean_angle_indicator.dart';
import 'metric_tile.dart';
import 'performance_summary.dart';
import 'ride_action_button.dart';
import 'ride_status_chip.dart';
import 'speed_readout.dart';
import '../../../core/widgets/hybrid_card.dart';
import '../../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../social/feed/widgets/quick_squad_feed_button.dart';
/// The single authoritative responsive layout for the MotorSignal dashboard.
///
/// Adapts composition based on available [ResponsiveSnapshot] constraints
/// and user-selected [DashboardLayout] mode (Sport / Touring / Minimal).
///
/// Does NOT rebuild on 20 Hz telemetry ticks; high-frequency telemetry
/// is consumed directly by leaf visualizers.
class ResponsiveDashboardLayout extends ConsumerWidget {
const ResponsiveDashboardLayout({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final layoutMode = ref.watch(dashboardConfigProvider);
final session = ref.watch(rideStateMachineProvider);
final snapshot = context.responsive;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: snapshot.isShortViewport ? 10.0 : 16.0,
vertical: snapshot.isShortViewport ? 6.0 : 12.0,
),
child: snapshot.isLandscape
? (snapshot.isShortViewport
? _buildShortLandscapeLayout(context, layoutMode, session)
: _buildExpandedLayout(context, layoutMode, session, snapshot))
: _buildCompactPortraitLayout(context, layoutMode, session),
);
}
// --------------------------------------------------------------------------
// COMPACT PORTRAIT
// --------------------------------------------------------------------------
Widget _buildCompactPortraitLayout(
BuildContext context,
DashboardLayout mode,
RideSession session,
) {
return LayoutBuilder(
builder: (context, constraints) {
// Group-ride alerts reduce the usable height above this layout.
final isShortPortrait = constraints.maxHeight < 880;
final isUltraShortPortrait = constraints.maxHeight < 500;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Top status bar
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
DashboardLayoutSwitcher(),
SizedBox(width: 4),
QuickSwarmReportButton(compact: true),
SizedBox(width: 4),
QuickSquadFeedButton(compact: true),
SizedBox(width: 4),
GPSStatusIndicator(),
SizedBox(width: 8),
HeadingIndicator(),
],
),
),
),
const SizedBox(width: 4),
const FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PerformanceSummary(),
SizedBox(width: 6),
RideStatusChip(),
],
),
),
],
),
// Body based on mode
if (mode == DashboardLayout.minimal) ...[
if (!isShortPortrait)
const Spacer(flex: 2)
else
const Spacer(flex: 1),
const Flexible(
flex: 6,
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
),
if (!isShortPortrait)
const Spacer(flex: 2)
else
const Spacer(flex: 1),
HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isShortPortrait ? 4 : 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Center(
child: MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(2),
unit: 'MI',
compact: isShortPortrait,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'STATUS',
value: session.state.name.toUpperCase(),
compact: isShortPortrait,
),
),
),
],
),
),
SizedBox(height: isShortPortrait ? 6 : 16),
RideActionButton(compact: isShortPortrait),
if (!isUltraShortPortrait) ...[
const SizedBox(height: 6),
PerformanceLaunchShortcut(compact: isShortPortrait),
],
] else if (mode == DashboardLayout.touring) ...[
SizedBox(height: isShortPortrait ? 2 : 6),
SpeedReadout(
size: isShortPortrait
? SpeedReadoutSize.compact
: SpeedReadoutSize.compact,
),
SizedBox(height: isShortPortrait ? 4 : 8),
const Expanded(flex: 5, child: DashboardMapPanel()),
SizedBox(height: isShortPortrait ? 4 : 10),
HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: isShortPortrait ? 4 : 6,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Center(
child: MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(1),
unit: 'MI',
compact: true,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'AVG',
value: session.averageSpeedMph.toStringAsFixed(1),
unit: 'MPH',
compact: true,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'ELEV',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
compact: true,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'MOVING',
value: '${(session.movingTimeSeconds / 60).floor()}m',
compact: true,
),
),
),
],
),
),
SizedBox(height: isShortPortrait ? 4 : 10),
RideActionButton(compact: isShortPortrait),
if (!isUltraShortPortrait) ...[
const SizedBox(height: 6),
PerformanceLaunchShortcut(compact: isShortPortrait),
],
] else ...[
// Sport Cockpit Mode
if (!isShortPortrait) const Spacer(flex: 1),
Flexible(
flex: isShortPortrait ? 3 : 4,
child: const Center(
child: SpeedReadout(size: SpeedReadoutSize.hero),
),
),
if (!isShortPortrait) const Spacer(flex: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: GForceIndicator(compact: isShortPortrait)),
Expanded(child: LeanAngleIndicator(compact: isShortPortrait)),
],
),
SizedBox(height: isShortPortrait ? 4 : 14),
HybridCard(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: isShortPortrait ? 4 : 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Center(
child: MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(2),
unit: 'MI',
icon: Icons.pin_drop_outlined,
compact: isShortPortrait,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'TOP SPD',
value: session.topSpeedMph.toStringAsFixed(0),
unit: 'MPH',
icon: Icons.speed_outlined,
compact: isShortPortrait,
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'AVG SPD',
value: session.averageSpeedMph.toStringAsFixed(1),
unit: 'MPH',
icon: Icons.av_timer_outlined,
compact: isShortPortrait,
),
),
),
],
),
),
SizedBox(height: isShortPortrait ? 4 : 14),
RideActionButton(compact: isShortPortrait),
if (!isUltraShortPortrait) ...[
const SizedBox(height: 6),
PerformanceLaunchShortcut(compact: isShortPortrait),
],
],
],
);
},
);
}
// --------------------------------------------------------------------------
// SHORT LANDSCAPE (Cockpit Horizontal Mount)
// --------------------------------------------------------------------------
Widget _buildShortLandscapeLayout(
BuildContext context,
DashboardLayout mode,
RideSession session,
) {
if (mode == DashboardLayout.touring) {
return Row(
children: [
// Left side: Mode-driven embedded map
const Expanded(flex: 55, child: DashboardMapPanel()),
const SizedBox(width: 8),
// Right side: Cockpit telemetry
Expanded(
flex: 45,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DashboardLayoutSwitcher(),
GPSStatusIndicator(showLabel: false),
HeadingIndicator(compact: true),
RideStatusChip(),
],
),
const Center(
child: SpeedReadout(size: SpeedReadoutSize.compact),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(1),
unit: 'MI',
compact: true,
),
MetricTile(
label: 'AVG',
value: session.averageSpeedMph.toStringAsFixed(0),
unit: 'MPH',
compact: true,
),
MetricTile(
label: 'ELEV',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
compact: true,
),
],
),
const RideActionButton(compact: true),
],
),
),
],
);
}
if (mode == DashboardLayout.minimal) {
return Row(
children: [
const Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DashboardLayoutSwitcher(),
GPSStatusIndicator(),
HeadingIndicator(compact: true),
RideStatusChip(),
],
),
const Expanded(
child: Center(child: SpeedReadout(size: SpeedReadoutSize.hero)),
),
SizedBox(
width: 140,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(1),
unit: 'MI',
),
const SizedBox(height: 12),
const RideActionButton(compact: true),
],
),
),
],
);
}
// Default: Sport Cockpit Horizontal
return Row(
children: [
// Left Wing: G-Force + Trip Stats
Expanded(
flex: 28,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const GForceIndicator(compact: true),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(1),
unit: 'MI',
compact: true,
),
const SizedBox(width: 8),
MetricTile(
label: 'TOP',
value: session.topSpeedMph.toStringAsFixed(0),
unit: 'MPH',
compact: true,
),
],
),
),
],
),
),
// Center: Speedometer & Controls
Expanded(
flex: 44,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
DashboardLayoutSwitcher(),
SizedBox(width: 4),
GPSStatusIndicator(showLabel: false),
SizedBox(width: 4),
HeadingIndicator(compact: true),
SizedBox(width: 4),
RideStatusChip(),
],
),
),
const Center(child: SpeedReadout(size: SpeedReadoutSize.compact)),
const RideActionButton(compact: true),
const SizedBox(height: 6),
const PerformanceLaunchShortcut(compact: true),
],
),
),
// Right Wing: Lean Angle + Avg Speed
Expanded(
flex: 28,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const LeanAngleIndicator(compact: true),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
MetricTile(
label: 'AVG',
value: session.averageSpeedMph.toStringAsFixed(0),
unit: 'MPH',
compact: true,
),
const SizedBox(width: 8),
const PerformanceSummary(compact: true),
],
),
),
],
),
),
],
);
}
// --------------------------------------------------------------------------
// MEDIUM / EXPANDED (Tablets and Large Displays)
// --------------------------------------------------------------------------
Widget _buildExpandedLayout(
BuildContext context,
DashboardLayout mode,
RideSession session,
ResponsiveSnapshot snapshot,
) {
if (mode == DashboardLayout.touring) {
return Row(
children: [
const Expanded(flex: 60, child: DashboardMapPanel()),
const SizedBox(width: 16),
Expanded(
flex: 40,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DashboardLayoutSwitcher(),
GPSStatusIndicator(),
HeadingIndicator(),
RideStatusChip(),
],
),
const Spacer(),
const Center(
child: SpeedReadout(size: SpeedReadoutSize.regular),
),
const Spacer(),
HybridCard(
padding: const EdgeInsets.all(14),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(2),
unit: 'MI',
),
MetricTile(
label: 'TOP SPD',
value: session.topSpeedMph.toStringAsFixed(0),
unit: 'MPH',
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
MetricTile(
label: 'AVG SPD',
value: session.averageSpeedMph.toStringAsFixed(1),
unit: 'MPH',
),
MetricTile(
label: 'ELEV GAIN',
value: (session.elevationGain * 3.28084).toStringAsFixed(0),
unit: 'FT',
),
],
),
],
),
),
const SizedBox(height: 16),
const RideActionButton(),
],
),
),
],
);
}
// Default Sport Multi-Column
return Row(
children: [
// Left Wing: G-Force & Records
Expanded(
flex: 30,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const GForceIndicator(),
const SizedBox(height: 16),
HybridCard(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Center(
child: MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(2),
unit: 'MI',
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'TOP SPD',
value: session.topSpeedMph.toStringAsFixed(0),
unit: 'MPH',
),
),
),
],
),
),
],
),
),
const SizedBox(width: 16),
// Center: Status + Speed + Action
Expanded(
flex: 40,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
DashboardLayoutSwitcher(),
SizedBox(width: 4),
GPSStatusIndicator(),
SizedBox(width: 4),
HeadingIndicator(),
],
),
),
),
const SizedBox(width: 4),
const FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: RideStatusChip(),
),
],
),
Center(
child: Transform.translate(
offset: Offset(0, -20),
child: SpeedReadout(size: SpeedReadoutSize.hero),
),
),
const RideActionButton(),
const SizedBox(height: 6),
const PerformanceLaunchShortcut(),
],
),
),
const SizedBox(width: 16),
// Right Wing: Lean Angle & Averages
Expanded(
flex: 30,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const LeanAngleIndicator(),
const SizedBox(height: 16),
HybridCard(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Center(
child: MetricTile(
label: 'AVG SPD',
value: session.averageSpeedMph.toStringAsFixed(1),
unit: 'MPH',
),
),
),
Expanded(
child: Center(
child: MetricTile(
label: 'MOVING',
value: '${(session.movingTimeSeconds / 60).floor()}m',
),
),
),
],
),
),
],
),
),
],
);
}
}
/// Fast dashboard entry point for the launch timer used before a run.
class PerformanceLaunchShortcut extends ConsumerWidget {
final bool compact;
const PerformanceLaunchShortcut({super.key, this.compact = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
final attempt = ref.watch(performanceLaunchProvider);
// Derive canonical user-facing status string
final String statusText;
final Color statusColor;
if (attempt.isFalseStart) {
statusText = 'FALSE START';
statusColor = colors.raceRed;
} else {
switch (attempt.state) {
case PerformanceLaunchState.countdown:
statusText = 'COUNTDOWN';
statusColor = colors.warningAmber;
break;
case PerformanceLaunchState.launched:
statusText = 'RUNNING';
statusColor = colors.brandBlue;
break;
case PerformanceLaunchState.targetReached:
case PerformanceLaunchState.completed:
statusText = 'COMPLETE';
statusColor = colors.launchGreen;
break;
case PerformanceLaunchState.ready:
statusText = 'ARMED';
statusColor = colors.launchGreen;
break;
case PerformanceLaunchState.aborted:
statusText = 'ABORTED';
statusColor = colors.raceRed;
break;
case PerformanceLaunchState.idle:
if (attempt.armedAtMonotonicMs != null) {
statusText = 'WAITING';
statusColor = colors.warningAmber;
} else {
statusText = 'READY';
statusColor = colors.brandBlue;
}
break;
}
}
return Semantics(
button: true,
label: 'Performance Launch. Status: $statusText. Modes: 0 to 60 MPH, 0 to 100 MPH. Tap to open launch timer.',
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('dashboard_performance_launch_card'),
onTap: () => context.push('/launch'),
borderRadius: BorderRadius.circular(8),
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraNarrow = constraints.maxWidth < 180;
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: isUltraNarrow ? 6 : (compact ? 10 : 14),
vertical: compact ? 5 : 7,
),
decoration: BoxDecoration(
color: colors.surfaceCardPrimary,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.surfaceBorder,
width: 1.0,
),
),
child: isUltraNarrow
? Column(
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
'PERFORMANCE LAUNCH',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w900,
letterSpacing: 0.4,
color: onSurface,
),
),
],
),
),
const SizedBox(height: 1),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'0–60 MPH • 0–100 MPH',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w600,
color: onSurfaceVariant,
),
),
),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
),
child: Text(
statusText,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: statusColor,
),
),
),
),
],
)
: Row(
children: [
Icon(
Icons.bolt,
size: compact ? 16 : 18,
color: statusColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'PERFORMANCE LAUNCH',
style: TextStyle(
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w900,
letterSpacing: 0.5,
color: onSurface,
),
),
),
const SizedBox(height: 1),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'0–60 MPH • 0–100 MPH',
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w600,
color: onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(width: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
),
child: Text(
statusText,
style: TextStyle(
fontSize: compact ? 8 : 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.4,
color: statusColor,
),
),
),
),
const SizedBox(width: 4),
Icon(
Icons.chevron_right,
size: compact ? 16 : 18,
color: onSurfaceVariant,
),
],
),
);
},
),
),
),
);
}
}