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

681 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/responsive/viewport_size.dart';
import '../../../core/theme/app_colors.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';
/// 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) {
final isShortPortrait = constraints.maxHeight < 620;
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),
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),
Container(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isShortPortrait ? 4 : 10,
),
decoration: _boxDecoration(context),
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),
] 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),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: isShortPortrait ? 4 : 6,
),
decoration: _boxDecoration(context),
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.toStringAsFixed(0),
unit: 'M',
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),
] 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),
Container(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: isShortPortrait ? 4 : 8,
),
decoration: _boxDecoration(context),
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),
],
],
);
},
);
}
// --------------------------------------------------------------------------
// 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.toStringAsFixed(0),
unit: 'M',
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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
MetricTile(
label: 'TRIP',
value: session.tripDistanceMiles.toStringAsFixed(1),
unit: 'MI',
compact: true,
),
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 Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DashboardLayoutSwitcher(),
GPSStatusIndicator(showLabel: false),
HeadingIndicator(compact: true),
RideStatusChip(),
],
),
const Center(
child: SpeedReadout(size: SpeedReadoutSize.compact),
),
const RideActionButton(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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
MetricTile(
label: 'AVG',
value: session.averageSpeedMph.toStringAsFixed(0),
unit: 'MPH',
compact: true,
),
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(),
Container(
padding: const EdgeInsets.all(14),
decoration: _boxDecoration(context),
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.toStringAsFixed(0),
unit: 'M',
),
],
),
],
),
),
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),
Container(
padding: const EdgeInsets.all(12),
decoration: _boxDecoration(context),
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(),
),
],
),
const Center(
child: SpeedReadout(size: SpeedReadoutSize.hero),
),
const RideActionButton(),
],
),
),
const SizedBox(width: 16),
// Right Wing: Lean Angle & Averages
Expanded(
flex: 30,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const LeanAngleIndicator(),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: _boxDecoration(context),
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',
),
),
),
],
),
),
],
),
),
],
);
}
BoxDecoration _boxDecoration(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return BoxDecoration(
color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppColors.darkOutlineVariant : AppColors.lightOutlineVariant,
width: 1.0,
),
);
}
}