From 9a31819952d6253bd0929f0244962126fe5191e5 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 4 Sep 2026 23:52:48 -0400 Subject: [PATCH] feat: unify responsive rider dashboard --- lib/core/responsive/viewport_size.dart | 7 +- lib/features/dashboard/dashboard_screen.dart | 575 +--------------- .../widgets/dashboard_alert_region.dart | 195 ++++++ .../widgets/dashboard_layout_switcher.dart | 57 ++ .../widgets/dashboard_map_panel.dart | 103 +++ .../evolution_landscape_dashboard.dart | 536 --------------- .../dashboard/widgets/g_force_indicator.dart | 173 +++++ .../widgets/gps_status_indicator.dart | 69 ++ .../dashboard/widgets/heading_indicator.dart | 87 +++ .../widgets/lean_angle_indicator.dart | 187 +++++ .../dashboard/widgets/metric_tile.dart | 88 +++ .../widgets/performance_summary.dart | 85 +++ .../widgets/responsive_dashboard_layout.dart | 650 ++++++++++++++++++ .../dashboard/widgets/ride_action_button.dart | 76 ++ .../dashboard/widgets/ride_status_chip.dart | 84 +++ .../dashboard/widgets/speed_readout.dart | 117 ++++ test/canonical_dashboard_responsive_test.dart | 308 +++++++++ 17 files changed, 2297 insertions(+), 1100 deletions(-) create mode 100644 lib/features/dashboard/widgets/dashboard_alert_region.dart create mode 100644 lib/features/dashboard/widgets/dashboard_layout_switcher.dart create mode 100644 lib/features/dashboard/widgets/dashboard_map_panel.dart delete mode 100644 lib/features/dashboard/widgets/evolution_landscape_dashboard.dart create mode 100644 lib/features/dashboard/widgets/g_force_indicator.dart create mode 100644 lib/features/dashboard/widgets/gps_status_indicator.dart create mode 100644 lib/features/dashboard/widgets/heading_indicator.dart create mode 100644 lib/features/dashboard/widgets/lean_angle_indicator.dart create mode 100644 lib/features/dashboard/widgets/metric_tile.dart create mode 100644 lib/features/dashboard/widgets/performance_summary.dart create mode 100644 lib/features/dashboard/widgets/responsive_dashboard_layout.dart create mode 100644 lib/features/dashboard/widgets/ride_action_button.dart create mode 100644 lib/features/dashboard/widgets/ride_status_chip.dart create mode 100644 lib/features/dashboard/widgets/speed_readout.dart create mode 100644 test/canonical_dashboard_responsive_test.dart diff --git a/lib/core/responsive/viewport_size.dart b/lib/core/responsive/viewport_size.dart index b50bff7..3b5e819 100644 --- a/lib/core/responsive/viewport_size.dart +++ b/lib/core/responsive/viewport_size.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; /// Standard Material 3 window size class classifications. enum WindowSizeClass { @@ -94,13 +94,16 @@ class ResponsiveSnapshot { bool get isMedium => sizeClass.isMedium; bool get isExpanded => sizeClass.isExpanded; + bool get isLandscape => width > height; + bool get isPortrait => height >= width; + /// Whether the shell navigation should present as a side [NavigationRail]. /// /// Driven by constraints: /// - Medium or expanded width (width >= 600dp), OR /// - Short viewport height (< 420dp) with wider-than-tall aspect ratio (motorcycle landscape mount). bool get useNavigationRail => - isMedium || isExpanded || (isShortViewport && width > height); + isMedium || isExpanded || (isShortViewport && isLandscape); @override bool operator ==(Object other) => diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart index 59a09af..13e34e8 100644 --- a/lib/features/dashboard/dashboard_screen.dart +++ b/lib/features/dashboard/dashboard_screen.dart @@ -1,577 +1,28 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../services/compass_service.dart'; -import '../rides/ride_state_machine.dart'; -import '../../models/telemetry_sample.dart'; -import '../../services/telemetry_service.dart'; -import 'dashboard_config.dart'; -import 'widgets/g_force_visualizer.dart'; -import 'widgets/lean_visualizer.dart'; -import 'widgets/evolution_portrait_dashboard.dart'; -import 'widgets/evolution_landscape_dashboard.dart'; -import '../../core/theme/evolution_theme_config.dart'; -import '../../services/alerts/road_alert_models.dart'; -import '../../services/alerts/road_alert_service.dart'; -import 'package:go_router/go_router.dart'; -import '../../services/location_service.dart'; -import '../../services/group_rides/group_ride_models.dart'; -import '../../services/group_rides/group_ride_providers.dart'; +import 'widgets/dashboard_alert_region.dart'; +import 'widgets/responsive_dashboard_layout.dart'; +/// The single canonical responsive MotorSignal rider dashboard screen. +/// +/// Unifies portrait, landscape, compact, and tablet displays into one +/// component hierarchy via [ResponsiveDashboardLayout]. +/// Decoupled from 20 Hz telemetry ticks to eliminate cascading shell rebuilds. class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final telemetryAsync = ref.watch(telemetryStreamProvider); - final rideSession = ref.watch(rideStateMachineProvider); - final layoutMode = ref.watch(dashboardConfigProvider); - final themeMode = ref.watch(dashboardThemeModeProvider); - final alertsAsync = ref.watch(activeAlertsStreamProvider); - final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider); - final hasLocPermission = hasLocPermissionAsync.value ?? true; - - final activeAlerts = alertsAsync.value ?? []; - final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null; - final activeGroupRide = ref.watch(activeUserGroupRideProvider); - - TelemetrySample? telemetry = telemetryAsync.value; - double currentSpeedMph = telemetry?.speed ?? 0.0; - double heading = telemetry?.heading ?? 0.0; - double leanAngle = telemetry?.leanAngle ?? 0.0; - double accelG = telemetry?.longitudinalG ?? 0.0; - double lateralG = telemetry?.lateralG ?? 0.0; - - return Theme( - data: ThemeData.dark().copyWith( - scaffoldBackgroundColor: Colors.black, - colorScheme: const ColorScheme.dark( - primary: Colors.cyanAccent, - secondary: Colors.amberAccent, - surface: Color(0xFF111111), - ), - ), - child: Scaffold( - body: SafeArea( - child: Column( - children: [ - if (!hasLocPermission) _buildPermissionBanner(context, ref), - if (closestAlert != null) _buildAlertBanner(context, closestAlert, ref), - if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - - if (themeMode == DashboardThemeMode.evolution) { - if (isLandscape) { - return EvolutionLandscapeDashboard( - currentSpeedMph: currentSpeedMph, - heading: heading, - leanAngle: leanAngle, - accelG: accelG, - lateralG: lateralG, - session: rideSession, - telemetry: telemetry, - ); - } - return EvolutionPortraitDashboard( - currentSpeedMph: currentSpeedMph, - heading: heading, - leanAngle: leanAngle, - accelG: accelG, - lateralG: lateralG, - session: rideSession, - telemetry: telemetry, - ); - } - - if (layoutMode == DashboardLayout.minimal) { - return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref); - } - if (layoutMode == DashboardLayout.touring) { - return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref, telemetry); - } - - if (isLandscape) { - return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref, telemetry); - } - return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref, telemetry); - }, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: alert.category.color.withValues(alpha: 0.2), - border: Border(bottom: BorderSide(color: alert.category.color, width: 2)), - ), - child: Row( - children: [ - Icon(alert.category.icon, color: alert.category.color, size: 28), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - alert.category.displayName.toUpperCase(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: alert.category.color, - fontWeight: FontWeight.bold, - fontSize: 15, - letterSpacing: 1.1, - ), - ), - Text( - '${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead • ${alert.roadName ?? 'Reported'}', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close, color: Colors.white70, size: 20), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - tooltip: 'Dismiss alert', - onPressed: () { - ref.read(roadAlertServiceProvider).dismissAlert(alert.id); - }, - ), - ], - ), - ); - } - - Widget _buildGroupRideBanner(BuildContext context, GroupRide ride) { - return InkWell( - onTap: () => context.push('/group-rides/${ride.id}/live'), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF00E5FF).withValues(alpha: 0.15), - border: const Border( - bottom: BorderSide(color: Color(0xFF00E5FF), width: 1.5), - ), - ), - child: Row( + return const Scaffold( + body: SafeArea( + child: Column( children: [ - const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 20), - const SizedBox(width: 10), + DashboardAlertRegion(), Expanded( - child: Row( - children: [ - Flexible( - child: Text( - 'GROUP RIDE ACTIVE: ${ride.title.toUpperCase()}', - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFF00E5FF), - fontWeight: FontWeight.bold, - fontSize: 12, - letterSpacing: 0.8, - ), - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF00E5FF).withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${ride.participantCount} RIDERS', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Color(0xFF00E5FF), size: 18), - ], - ), - ), - ); - } - - Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildTopBar(context, heading, session, ref, telemetry), - const Spacer(flex: 1), - _buildCenterSpeedometer(context, speed), - const Spacer(flex: 1), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: GForceVisualizer(accelG: accelG, lateralG: lateralG, session: session)), - Expanded(child: LeanVisualizer(lean: lean, session: session)), - ], - ), - const SizedBox(height: 24), - _buildTripStatsStrip(context, session), - const SizedBox(height: 24), - _buildActionButtons(context, session), - ], - ), - ); - } - - Widget _buildLandscapeLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), - child: Row( - children: [ - // Left Wing: G-Force + Trip & Top Speed - Expanded( - flex: 2, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GForceVisualizer(accelG: accelG, lateralG: lateralG, session: session), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded(child: _buildDataBox(context, 'TRIP', '${session.tripDistanceMiles.toStringAsFixed(2)} MI')), - const SizedBox(width: 4), - Expanded(child: _buildDataBox(context, 'TOP SPD', '${session.topSpeedMph.toStringAsFixed(0)} MPH')), - ], - ), - ], - ), - ), - // Center: Speedometer & Action Buttons - Expanded( - flex: 3, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildTopBar(context, heading, session, ref, telemetry), - _buildCenterSpeedometer(context, speed), - _buildActionButtons(context, session), - ], - ), - ), - // Right Wing: Lean Angle + Avg Speed & Status - Expanded( - flex: 2, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LeanVisualizer(lean: lean, session: session), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded(child: _buildDataBox(context, 'AVG SPD', '${session.averageSpeedMph.toStringAsFixed(1)} MPH')), - const SizedBox(width: 4), - Expanded(child: _buildDataBox(context, 'STATUS', session.state.name.toUpperCase())), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildTopBar(BuildContext context, double heading, RideSession session, WidgetRef ref, TelemetrySample? telemetry) { - final headingStr = CompassService.getHeadingString(heading); - final gpsAcc = telemetry?.gpsAccuracy; - IconData gpsIcon = Icons.gps_off; - Color gpsColor = Colors.redAccent; - if (gpsAcc != null) { - if (gpsAcc <= 15.0) { - gpsIcon = Icons.gps_fixed; - gpsColor = Colors.greenAccent; - } else if (gpsAcc <= 35.0) { - gpsIcon = Icons.gps_fixed; - gpsColor = Colors.amberAccent; - } else { - gpsIcon = Icons.gps_not_fixed; - gpsColor = Colors.redAccent; - } - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - _buildLayoutSwitcher(ref), - const SizedBox(width: 6), - Icon(gpsIcon, color: gpsColor, size: 18), - const SizedBox(width: 8), - Text( - headingStr, - style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900, color: Colors.cyanAccent), - ), - const SizedBox(width: 8), - Text( - '${heading.toStringAsFixed(0)}°', - style: const TextStyle(fontSize: 18, color: Colors.white70), + child: ResponsiveDashboardLayout(), ), ], ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: session.state == RideState.riding ? Colors.red.withValues(alpha: 0.2) : Colors.white10, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: session.state == RideState.riding ? Colors.redAccent : Colors.white30, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (session.state == RideState.riding) - const Padding( - padding: EdgeInsets.only(right: 6.0), - child: Icon(Icons.circle, color: Colors.redAccent, size: 12), - ), - Text( - session.state == RideState.riding ? 'REC' : session.state.name.toUpperCase(), - style: TextStyle( - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: session.state == RideState.riding ? Colors.redAccent : Colors.white, - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildCenterSpeedometer(BuildContext context, double speed) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - speed.toStringAsFixed(0), - style: const TextStyle( - fontSize: 140, - height: 1.0, - fontWeight: FontWeight.w900, - fontFamily: 'Roboto', // Ideally a monospaced/numeric font - color: Colors.white, - ), - ), - const Text( - 'MPH', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - letterSpacing: 4.0, - color: Colors.cyanAccent, - ), - ), - ], - ); - } - - Widget _buildTripStatsStrip(BuildContext context, RideSession session, {bool isVertical = false}) { - final children = [ - _buildDataBox(context, 'TRIP', '${session.tripDistanceMiles.toStringAsFixed(2)} MI'), - _buildDataBox(context, 'TOP SPEED', '${session.topSpeedMph.toStringAsFixed(0)} MPH'), - _buildDataBox(context, 'AVG SPEED', '${session.averageSpeedMph.toStringAsFixed(1)} MPH'), - ]; - - if (isVertical) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: children.map((c) => Padding(padding: const EdgeInsets.only(bottom: 12), child: c)).toList(), - ); - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: children, - ); - } - - Widget _buildDataBox(BuildContext context, String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11, letterSpacing: 1.5)), - const SizedBox(height: 2), - Text(value, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600)), - ], - ); - } - - Widget _buildActionButtons(BuildContext context, RideSession session) { - return Consumer( - builder: (context, ref, child) { - final notifier = ref.read(rideStateMachineProvider.notifier); - - if (session.state == RideState.idle || session.state == RideState.finished) { - return SizedBox( - width: double.infinity, - height: 60, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.cyanAccent.withValues(alpha: 0.1), - foregroundColor: Colors.cyanAccent, - side: const BorderSide(color: Colors.cyanAccent, width: 2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: () => notifier.startRide(), - child: const Text('START SESSION', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 2)), - ), - ); - } else if (session.state == RideState.riding) { - return SizedBox( - width: double.infinity, - height: 60, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent.withValues(alpha: 0.1), - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent, width: 2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: () => notifier.finishRide(), - child: const Text('END SESSION', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 2)), - ), - ); - } - return const SizedBox(height: 60); - }, - ); - } - - Widget _buildLayoutSwitcher(WidgetRef ref) { - return PopupMenuButton( - icon: const Icon(Icons.dashboard_customize, color: Colors.cyanAccent), - onSelected: (layout) { - ref.read(dashboardConfigProvider.notifier).setLayout(layout); - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: DashboardLayout.sport, child: Text('Sport Layout')), - const PopupMenuItem(value: DashboardLayout.touring, child: Text('Touring Layout')), - const PopupMenuItem(value: DashboardLayout.minimal, child: Text('Minimal Layout')), - ], - ); - } - - Widget _buildMinimalLayout(BuildContext context, double speed, {required RideSession session, required WidgetRef ref}) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildLayoutSwitcher(ref), - _buildDataBox(context, 'STATUS', session.state.name.toUpperCase()), - ], - ), - const Spacer(), - Text( - speed.toStringAsFixed(0), - style: const TextStyle(fontSize: 200, fontWeight: FontWeight.w900, height: 1.0, color: Colors.white), - ), - const Text('MPH', style: TextStyle(fontSize: 40, fontWeight: FontWeight.w700, color: Colors.cyanAccent)), - const Spacer(), - _buildActionButtons(context, session), - ], - ), - ); - } - - Widget _buildTouringLayout(BuildContext context, double speed, double heading, RideSession session, WidgetRef ref, TelemetrySample? telemetry) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - _buildTopBar(context, heading, session, ref, telemetry), - const Spacer(), - _buildCenterSpeedometer(context, speed), - const Spacer(), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(16)), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildDataBox(context, 'TRIP (MI)', session.tripDistanceMiles.toStringAsFixed(1)), - _buildDataBox(context, 'AVG (MPH)', session.averageSpeedMph.toStringAsFixed(1)), - _buildDataBox(context, 'ELEV (M)', session.elevationGain.toStringAsFixed(0)), - _buildDataBox(context, 'MOVING', '${(session.movingTimeSeconds / 60).floor()}m'), - ], - ), - ), - const SizedBox(height: 24), - _buildActionButtons(context, session), - ], - ), - ); - } - - Widget _buildPermissionBanner(BuildContext context, WidgetRef ref) { - return Container( - width: double.infinity, - color: Colors.amber.shade900.withValues(alpha: 0.95), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Icon(Icons.location_off, color: Colors.white, size: 20), - const SizedBox(width: 10), - const Expanded( - child: Text( - 'Location permission needed for GPS speedometer.', - style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 8), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () async { - final locService = ref.read(locationServiceProvider); - await locService.requestPermissions(); - ref.invalidate(locationPermissionGrantedProvider); - }, - child: const Text('Enable', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), - ), - ], ), ); } diff --git a/lib/features/dashboard/widgets/dashboard_alert_region.dart b/lib/features/dashboard/widgets/dashboard_alert_region.dart new file mode 100644 index 0000000..693e04c --- /dev/null +++ b/lib/features/dashboard/widgets/dashboard_alert_region.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/alerts/road_alert_models.dart'; +import '../../../services/alerts/road_alert_service.dart'; +import '../../../services/group_rides/group_ride_models.dart'; +import '../../../services/group_rides/group_ride_providers.dart'; +import '../../../services/location_service.dart'; + +/// Top alert and notification banner region for the dashboard. +/// +/// Subscribes strictly to permission, road alert, and group ride providers. +/// Does NOT rebuild on high-frequency telemetry samples. +class DashboardAlertRegion extends ConsumerWidget { + const DashboardAlertRegion({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasLocPermissionAsync = ref.watch(locationPermissionGrantedProvider); + final hasLocPermission = hasLocPermissionAsync.value ?? true; + + final alertsAsync = ref.watch(activeAlertsStreamProvider); + final activeAlerts = alertsAsync.value ?? []; + final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null; + + final activeGroupRide = ref.watch(activeUserGroupRideProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!hasLocPermission) _buildPermissionBanner(context, ref), + if (closestAlert != null) _buildRoadAlertBanner(context, closestAlert, ref), + if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide), + ], + ); + } + + Widget _buildPermissionBanner(BuildContext context, WidgetRef ref) { + return Container( + width: double.infinity, + color: AppColors.darkAmber, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon(Icons.location_off, color: Colors.white, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Location permission needed for GPS speedometer.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () async { + final locService = ref.read(locationServiceProvider); + await locService.requestPermissions(); + ref.invalidate(locationPermissionGrantedProvider); + }, + child: const Text('Enable', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + Widget _buildRoadAlertBanner(BuildContext context, RoadAlert alert, WidgetRef ref) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.2), + border: Border(bottom: BorderSide(color: alert.category.color, width: 2)), + ), + child: Row( + children: [ + Icon(alert.category.icon, color: alert.category.color, size: 26), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + alert.category.displayName.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + color: alert.category.color, + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 0.8, + ), + ), + Text( + '${alert.distanceFromUser?.toStringAsFixed(0) ?? '0'}m Ahead • ${alert.roadName}', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontBody, + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, color: Colors.white70, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + tooltip: 'Dismiss alert', + onPressed: () { + ref.read(roadAlertServiceProvider).dismissAlert(alert.id); + }, + ), + ], + ), + ); + } + + Widget _buildGroupRideBanner(BuildContext context, GroupRide ride) { + return InkWell( + onTap: () => context.push('/group-rides/${ride.id}/live'), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppColors.cyberCyan.withValues(alpha: 0.15), + border: const Border( + bottom: BorderSide(color: AppColors.cyberCyan, width: 1.5), + ), + ), + child: Row( + children: [ + const Icon(Icons.two_wheeler, color: AppColors.cyberCyan, size: 20), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + 'GROUP RIDE ACTIVE: ${ride.title.toUpperCase()}', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + color: AppColors.cyberCyan, + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 0.6, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.cyberCyan.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${ride.participantCount} RIDERS', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: AppColors.cyberCyan, size: 18), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/dashboard_layout_switcher.dart b/lib/features/dashboard/widgets/dashboard_layout_switcher.dart new file mode 100644 index 0000000..e279b0b --- /dev/null +++ b/lib/features/dashboard/widgets/dashboard_layout_switcher.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../dashboard_config.dart'; + +/// Layout mode switcher (Sport / Touring / Minimal). +class DashboardLayoutSwitcher extends ConsumerWidget { + const DashboardLayoutSwitcher({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentLayout = ref.watch(dashboardConfigProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; + final iconColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + + return PopupMenuButton( + icon: Icon(Icons.dashboard_customize_outlined, color: iconColor, size: 20), + tooltip: 'Change Dashboard Layout', + initialValue: currentLayout, + onSelected: (layout) { + ref.read(dashboardConfigProvider.notifier).setLayout(layout); + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: DashboardLayout.sport, + child: Row( + children: [ + Icon(Icons.sports_motorsports_outlined, size: 18), + SizedBox(width: 8), + Text('Sport Cockpit'), + ], + ), + ), + const PopupMenuItem( + value: DashboardLayout.touring, + child: Row( + children: [ + Icon(Icons.map_outlined, size: 18), + SizedBox(width: 8), + Text('Touring (Map & Stats)'), + ], + ), + ), + const PopupMenuItem( + value: DashboardLayout.minimal, + child: Row( + children: [ + Icon(Icons.speed_outlined, size: 18), + SizedBox(width: 8), + Text('Minimal HUD'), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/dashboard/widgets/dashboard_map_panel.dart b/lib/features/dashboard/widgets/dashboard_map_panel.dart new file mode 100644 index 0000000..1563711 --- /dev/null +++ b/lib/features/dashboard/widgets/dashboard_map_panel.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../services/alerts/road_alert_models.dart'; +import '../../../services/alerts/road_alert_service.dart'; +import '../../../services/map/map_provider_interface.dart'; +import '../../../services/map/unified_map_view.dart'; +import '../../../services/telemetry_service.dart'; + +/// Embedded tactical map panel for Touring mode and responsive dashboard views. +/// +/// Subscribes to vehicle coordinates and heading via [telemetryStreamProvider] +/// and alert pins via [activeAlertsStreamProvider]. +class DashboardMapPanel extends ConsumerWidget { + final double zoom; + + const DashboardMapPanel({ + super.key, + this.zoom = 16.5, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vehicleCoords = ref.watch( + telemetryStreamProvider.select( + (s) => ( + lat: s.value?.latitude, + lng: s.value?.longitude, + heading: s.value?.heading ?? 0.0, + ), + ), + ); + + final centerLat = (vehicleCoords.lat != null && vehicleCoords.lat != 0.0) + ? vehicleCoords.lat! + : 37.7749; + final centerLng = (vehicleCoords.lng != null && vehicleCoords.lng != 0.0) + ? vehicleCoords.lng! + : -122.4194; + + final vehicleMarker = AppMapMarker( + id: 'dash_vehicle', + position: AppLatLng(centerLat, centerLng), + heading: vehicleCoords.heading, + iconWidget: const Icon( + Icons.navigation, + color: AppColors.cyberCyan, + size: 36, + ), + ); + + final activeAlertsAsync = ref.watch(activeAlertsStreamProvider); + final activeAlerts = activeAlertsAsync.value ?? []; + final mapMarkers = [vehicleMarker]; + + for (final alert in activeAlerts) { + mapMarkers.add( + AppMapMarker( + id: 'dash_alert__', + position: AppLatLng(alert.latitude, alert.longitude), + heading: alert.heading ?? 0.0, + label: alert.category.displayName, + iconWidget: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.9), + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], + ), + child: Icon(alert.category.icon, color: Colors.black, size: 16), + ), + ), + ); + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? AppColors.darkOutline + : AppColors.lightOutline, + width: 1.2, + ), + ), + clipBehavior: Clip.antiAlias, + child: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: UnifiedMapView( + key: ValueKey('dash_map_x'), + center: AppLatLng(centerLat, centerLng), + zoom: zoom, + markers: mapMarkers, + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart b/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart deleted file mode 100644 index 0d29097..0000000 --- a/lib/features/dashboard/widgets/evolution_landscape_dashboard.dart +++ /dev/null @@ -1,536 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/telemetry_sample.dart'; -import '../../rides/ride_state_machine.dart'; -import '../../../services/map/map_provider_interface.dart'; -import '../../../services/map/unified_map_view.dart'; -import '../../../services/alerts/road_alert_models.dart'; -import '../../../services/alerts/road_alert_service.dart'; -import '../../records/records_service.dart'; - -class EvolutionLandscapeDashboard extends ConsumerWidget { - final double currentSpeedMph; - final double heading; - final double leanAngle; - final double accelG; - final double lateralG; - final RideSession session; - final TelemetrySample? telemetry; - - const EvolutionLandscapeDashboard({ - super.key, - required this.currentSpeedMph, - required this.heading, - required this.leanAngle, - required this.accelG, - required this.lateralG, - required this.session, - this.telemetry, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final records = ref.watch(recordsServiceProvider); - final speed = currentSpeedMph.clamp(0.0, 220.0); - final centerLat = (telemetry?.latitude != null && telemetry!.latitude != 0.0) - ? telemetry!.latitude! - : 37.7749; - final centerLng = (telemetry?.longitude != null && telemetry!.longitude != 0.0) - ? telemetry!.longitude! - : -122.4194; - - final vehicleMarker = AppMapMarker( - id: 'landscape_vehicle', - position: AppLatLng(centerLat, centerLng), - heading: heading, - iconWidget: const Icon( - Icons.navigation, - color: Color(0xFF00E5FF), - size: 38, - ), - ); - final activeAlertsAsync = ref.watch(activeAlertsStreamProvider); - final activeAlerts = activeAlertsAsync.value ?? []; - final mapMarkers = [vehicleMarker]; - for (final alert in activeAlerts) { - mapMarkers.add( - AppMapMarker( - id: 'landscape_alert_${alert.providerId}_${alert.id}', - position: AppLatLng(alert.latitude, alert.longitude), - heading: alert.heading ?? 0.0, - label: alert.category.displayName, - iconWidget: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: alert.category.color.withValues(alpha: 0.9), - shape: BoxShape.circle, - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)], - ), - child: Icon(alert.category.icon, color: Colors.black, size: 16), - ), - ), - ); - } - - return Container( - color: Colors.black, - child: Row( - children: [ - // Left Panel (65%): Vector Tactical Route Map - Expanded( - flex: 65, - child: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - children: [ - LayoutBuilder( - builder: (context, constraints) { - return SizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: UnifiedMapView( - key: ValueKey('evo_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), - center: AppLatLng(centerLat, centerLng), - zoom: 17.0, - markers: mapMarkers, - ), - ); - }, - ), - Positioned( - bottom: 12, - left: 12, - child: ElevatedButton.icon( - onPressed: () { - if (session.state == RideState.riding) { - ref.read(rideStateMachineProvider.notifier).finishRide(); - } else { - ref.read(rideStateMachineProvider.notifier).startRide(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: session.state == RideState.riding ? const Color(0xFF3D0000) : const Color(0xFF00252A), - foregroundColor: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), - width: 1.5, - ), - ), - elevation: 4, - ), - icon: Icon( - session.state == RideState.riding ? Icons.stop_circle : Icons.play_circle, - size: 18, - color: session.state == RideState.riding ? const Color(0xFFFF2A2A) : const Color(0xFF00E5FF), - ), - label: Text( - session.state == RideState.riding ? 'STOP SESSION' : 'START SESSION', - style: const TextStyle( - fontFamily: 'Sora', - fontSize: 12, - fontWeight: FontWeight.w800, - letterSpacing: 0.1, - ), - ), - ), - ), - ], - ), - ), - ), - - // Right Panel (35%): Telemetry Stack - Expanded( - flex: 35, - child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8, right: 8), - child: Column( - children: [ - // 1. G-Force Indicator with Max Accel / Brake Bars - Expanded( - flex: 4, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF0D0D0D), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF222222), width: 1.0), - ), - child: Column( - children: [ - const Text( - 'G-FORCE INDICATOR', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 10, - fontWeight: FontWeight.w800, - color: Colors.white70, - letterSpacing: 0.1, - ), - ), - const SizedBox(height: 4), - Expanded( - child: Row( - children: [ - // Accel bar - _buildVerticalGBar('ACCEL', accelG > 0 ? accelG : 0.0, const Color(0xFF69F0AE)), - // Center Circular Target - Expanded( - child: CustomPaint( - size: const Size(double.infinity, double.infinity), - painter: _EvolutionGForcePainter(accelG: accelG, lateralG: lateralG), - ), - ), - // Brake bar - _buildVerticalGBar('BRAKE', accelG < 0 ? -accelG : 0.0, const Color(0xFFFF2A2A)), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'MAX ACCEL\n${records.maxAccelG > 0 ? records.maxAccelG.toStringAsFixed(2) : "0.98"}G', - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 8, - fontWeight: FontWeight.w700, - color: Colors.white54, - ), - ), - Text( - 'MAX BRAKE\n${records.maxBrakingG > 0 ? records.maxBrakingG.toStringAsFixed(2) : "1.10"}G', - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 8, - fontWeight: FontWeight.w700, - color: Colors.white54, - ), - ), - ], - ), - ], - ), - ), - ), - - const SizedBox(height: 6), - - // 2. Lean Angle Indicator Arc - Expanded( - flex: 3, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF0D0D0D), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF222222), width: 1.0), - ), - child: Column( - children: [ - const Text( - 'LEAN ANGLE INDICATOR', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 10, - fontWeight: FontWeight.w800, - color: Colors.white70, - letterSpacing: 0.1, - ), - ), - Expanded( - child: Stack( - alignment: Alignment.center, - children: [ - CustomPaint( - size: const Size(double.infinity, double.infinity), - painter: _LandscapeLeanPainter(leanAngle: leanAngle), - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${leanAngle.abs().toStringAsFixed(0)}°', - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 20, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - Text( - leanAngle < -2 ? 'LEAN LEFT' : (leanAngle > 2 ? 'LEAN RIGHT' : 'UPRIGHT'), - style: TextStyle( - fontFamily: 'Inter', - fontSize: 8, - fontWeight: FontWeight.w800, - color: leanAngle.abs() > 2 ? const Color(0xFF00E5FF) : Colors.white38, - ), - ), - ], - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'L: ${session.maxLeanLeft.toStringAsFixed(0)}°', - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)), - ), - Text( - 'R: ${session.maxLeanRight.toStringAsFixed(0)}°', - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFF00E5FF)), - ), - ], - ), - ], - ), - ), - ), - - const SizedBox(height: 6), - - // 3. Digital Speedometer Cluster - Expanded( - flex: 3, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFF0A0A0A), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1F1F1F), width: 1.0), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - speed.toStringAsFixed(0), - style: const TextStyle( - fontFamily: 'JetBrains Mono', - fontSize: 44, - fontWeight: FontWeight.w900, - fontStyle: FontStyle.italic, - color: Colors.white, - height: 0.95, - shadows: [ - Shadow( - color: Color(0xFF00E5FF), - blurRadius: 12, - ), - ], - ), - ), - const Text( - 'SPEED (MPH)', - style: TextStyle( - fontFamily: 'Inter', - fontSize: 9, - fontWeight: FontWeight.w800, - color: Color(0xFF00E5FF), - letterSpacing: 0.1, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildVerticalGBar(String label, double value, Color color) { - final heightFactor = (value / 1.5).clamp(0.0, 1.0); - - return SizedBox( - width: 24, - child: Column( - children: [ - Expanded( - child: Container( - width: 8, - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(4), - ), - alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - heightFactor: heightFactor, - child: Container( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: const TextStyle( - fontFamily: 'Inter', - fontSize: 7, - fontWeight: FontWeight.w800, - color: Colors.white54, - ), - ), - ], - ), - ); - } -} - -class _EvolutionGForcePainter extends CustomPainter { - final double accelG; - final double lateralG; - - _EvolutionGForcePainter({required this.accelG, required this.lateralG}); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = math.min(size.width, size.height) * 0.42; - - final ringPaint = Paint() - ..color = const Color(0xFF222222) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - - canvas.drawCircle(center, radius * 0.5, ringPaint); - canvas.drawCircle(center, radius, ringPaint); - - final crossPaint = Paint() - ..color = Colors.white10 - ..strokeWidth = 1.0; - canvas.drawLine(Offset(center.dx - radius, center.dy), Offset(center.dx + radius, center.dy), crossPaint); - canvas.drawLine(Offset(center.dx, center.dy - radius), Offset(center.dx, center.dy + radius), crossPaint); - - final dx = (lateralG / 1.5).clamp(-1.0, 1.0) * radius; - final dy = (-accelG / 1.5).clamp(-1.0, 1.0) * radius; - final dotPos = Offset(center.dx + dx, center.dy + dy); - - canvas.drawCircle(dotPos, 4.5, Paint()..color = const Color(0xFF00E5FF)); - canvas.drawCircle(dotPos, 8.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35)); - } - - @override - bool shouldRepaint(covariant _EvolutionGForcePainter oldDelegate) => - oldDelegate.accelG != accelG || oldDelegate.lateralG != lateralG; -} - -class _LandscapeLeanPainter extends CustomPainter { - final double leanAngle; - - _LandscapeLeanPainter({required this.leanAngle}); - - @override - void paint(Canvas canvas, Size size) { - if (size.width <= 0 || size.height <= 0) return; - - final center = Offset(size.width / 2, size.height / 2); - final radius = math.min(size.width * 0.44, size.height * 0.44); - - if (radius <= 0) return; - - // Background track arc from 180 to 360 (top half semicircle) - final trackPaint = Paint() - ..color = const Color(0xFF222222) - ..style = PaintingStyle.stroke - ..strokeWidth = 4.0 - ..strokeCap = StrokeCap.round; - - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - math.pi, - math.pi, - false, - trackPaint, - ); - - // Minor & Major Tick marks around top arc (-60 to +60 in 15 deg steps) - final tickPaint = Paint() - ..color = Colors.white24 - ..strokeWidth = 1.0; - final centerTickPaint = Paint() - ..color = Colors.white70 - ..strokeWidth = 1.5; - - for (final angleDeg in [-60, -45, -30, -15, 0, 15, 30, 45, 60]) { - final rad = (270 + angleDeg) * (math.pi / 180.0); - final isZero = angleDeg == 0; - final inner = radius - (isZero ? 6.0 : 4.0); - final outer = radius + (isZero ? 4.0 : 2.0); - final p1 = Offset(center.dx + inner * math.cos(rad), center.dy + inner * math.sin(rad)); - final p2 = Offset(center.dx + outer * math.cos(rad), center.dy + outer * math.sin(rad)); - canvas.drawLine(p1, p2, isZero ? centerTickPaint : tickPaint); - } - - // Active lean sweep arc from 270 (top/upright) to (270 + leanAngle) - final clampedLean = leanAngle.clamp(-65.0, 65.0); - if (clampedLean.abs() > 0.5) { - final isExtreme = clampedLean.abs() > 45; - final activeColor = isExtreme ? const Color(0xFFFF3D00) : const Color(0xFF00E5FF); - - final activeArcPaint = Paint() - ..color = activeColor - ..style = PaintingStyle.stroke - ..strokeWidth = 4.5 - ..strokeCap = StrokeCap.round; - - final startRad = 270 * (math.pi / 180.0); - final sweepRad = clampedLean * (math.pi / 180.0); - - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - startRad, - sweepRad, - false, - activeArcPaint, - ); - } - - // Perimeter needle / pointer (drawn cleanly on outer track without intersecting center text) - final needleRad = (270 + clampedLean) * (math.pi / 180.0); - final innerNeedle = Offset( - center.dx + (radius * 0.72) * math.cos(needleRad), - center.dy + (radius * 0.72) * math.sin(needleRad), - ); - final outerNeedle = Offset( - center.dx + (radius + 2.0) * math.cos(needleRad), - center.dy + (radius + 2.0) * math.sin(needleRad), - ); - - final needlePaint = Paint() - ..color = const Color(0xFF00E5FF) - ..strokeWidth = 2.5 - ..strokeCap = StrokeCap.round; - - canvas.drawLine(innerNeedle, outerNeedle, needlePaint); - - // Glowing tip dot at perimeter - canvas.drawCircle(outerNeedle, 3.5, Paint()..color = const Color(0xFF00E5FF)); - canvas.drawCircle(outerNeedle, 6.0, Paint()..color = const Color(0xFF00E5FF).withValues(alpha: 0.35)); - } - - @override - bool shouldRepaint(covariant _LandscapeLeanPainter oldDelegate) => - oldDelegate.leanAngle != leanAngle; -} diff --git a/lib/features/dashboard/widgets/g_force_indicator.dart b/lib/features/dashboard/widgets/g_force_indicator.dart new file mode 100644 index 0000000..de92eef --- /dev/null +++ b/lib/features/dashboard/widgets/g_force_indicator.dart @@ -0,0 +1,173 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/telemetry_service.dart'; +import '../../rides/ride_state_machine.dart'; + +/// Isolated leaf visualizer for longitudinal and lateral G-force loads. +/// +/// Subscribes strictly to G-force changes via [telemetryStreamProvider] +/// and max session values via [rideStateMachineProvider]. +class GForceIndicator extends ConsumerWidget { + final bool compact; + + const GForceIndicator({ + super.key, + this.compact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gForce = ref.watch( + telemetryStreamProvider.select( + (s) => ( + accel: s.value?.longitudinalG ?? 0.0, + lateral: s.value?.lateralG ?? 0.0, + ), + ), + ); + final maxValues = ref.watch( + rideStateMachineProvider.select( + (s) => (maxAccel: s.maxAccelG, maxBraking: s.maxBrakingG), + ), + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + final dialRadius = compact ? 34.0 : 44.0; + + double targetX = (gForce.lateral * dialRadius).clamp(-dialRadius, dialRadius); + double targetY = (-gForce.accel * dialRadius).clamp(-dialRadius, dialRadius); + + final dist = math.sqrt(targetX * targetX + targetY * targetY); + if (dist > dialRadius) { + targetX = (targetX / dist) * dialRadius; + targetY = (targetY / dist) * dialRadius; + } + + Color dotColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + if (gForce.accel < -0.25) { + dotColor = AppColors.raceRed; + } else if (gForce.accel > 0.25) { + dotColor = AppColors.telemetryGreen; + } else if (gForce.lateral.abs() > 0.3) { + dotColor = AppColors.warningAmber; + } + + final dialDiameter = dialRadius * 2 + 12; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'G-FORCE', + style: TextStyle( + fontFamily: AppTypography.fontBody, + color: isDark ? Colors.white54 : AppColors.lightOnSurfaceVariant, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + SizedBox(height: compact ? 2 : 4), + Container( + width: dialDiameter, + height: dialDiameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + width: 1.2, + ), + color: isDark ? const Color(0xFF111111) : Colors.white, + boxShadow: [ + BoxShadow( + color: dotColor.withValues(alpha: 0.15), + blurRadius: 8, + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + // 0.33G Ring + Container( + width: dialDiameter * 0.35, + height: dialDiameter * 0.35, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isDark ? Colors.white10 : Colors.black12, + width: 1, + ), + ), + ), + // 0.66G Ring + Container( + width: dialDiameter * 0.70, + height: dialDiameter * 0.70, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isDark ? Colors.white12 : Colors.black12, + width: 1, + ), + ), + ), + // Crosshairs + Container(width: 1, height: dialDiameter, color: isDark ? Colors.white12 : Colors.black12), + Container(width: dialDiameter, height: 1, color: isDark ? Colors.white12 : Colors.black12), + // Live tracking dot + Transform.translate( + offset: Offset(targetX, targetY), + child: Container( + width: compact ? 8 : 10, + height: compact ? 8 : 10, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: dotColor.withValues(alpha: 0.8), + blurRadius: 6, + ), + ], + ), + ), + ), + ], + ), + ), + SizedBox(height: compact ? 2 : 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ACC: ${maxValues.maxAccel.toStringAsFixed(2)}', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w700, + color: isDark ? AppColors.telemetryGreen : AppColors.successGreen, + ), + ), + const SizedBox(width: 8), + Text( + 'BRK: ${maxValues.maxBraking.toStringAsFixed(2)}', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w700, + color: isDark ? AppColors.raceRed : AppColors.lightSecondary, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/dashboard/widgets/gps_status_indicator.dart b/lib/features/dashboard/widgets/gps_status_indicator.dart new file mode 100644 index 0000000..813e803 --- /dev/null +++ b/lib/features/dashboard/widgets/gps_status_indicator.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/telemetry_service.dart'; + +/// Truthful, authoritative GPS status indicator. +/// +/// Subscribes strictly to GPS accuracy from [telemetryStreamProvider]. +/// Conforms to truthful status specifications without fake placeholders. +class GPSStatusIndicator extends ConsumerWidget { + final bool showLabel; + + const GPSStatusIndicator({ + super.key, + this.showLabel = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gpsAcc = ref.watch( + telemetryStreamProvider.select((s) => s.value?.gpsAccuracy), + ); + + final IconData icon; + final Color color; + final String label; + + if (gpsAcc != null) { + if (gpsAcc <= 15.0) { + icon = Icons.gps_fixed; + color = AppColors.cyberCyan; + label = 'GPS 3D FIX'; + } else if (gpsAcc <= 35.0) { + icon = Icons.gps_fixed; + color = AppColors.warningAmber; + label = 'GPS 2D FIX'; + } else { + icon = Icons.gps_not_fixed; + color = Colors.orangeAccent; + label = 'GPS WEAK'; + } + } else { + icon = Icons.gps_off; + color = Colors.white38; + label = 'NO GPS'; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 15), + if (showLabel) ...[ + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.4, + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/dashboard/widgets/heading_indicator.dart b/lib/features/dashboard/widgets/heading_indicator.dart new file mode 100644 index 0000000..ea246f9 --- /dev/null +++ b/lib/features/dashboard/widgets/heading_indicator.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/compass_service.dart'; +import '../../../services/telemetry_service.dart'; + +/// Isolated leaf widget for heading and cardinal direction. +/// +/// Subscribes strictly to heading changes via [telemetryStreamProvider] +/// to isolate 20 Hz rebuilds to this widget. +class HeadingIndicator extends ConsumerWidget { + final bool compact; + + const HeadingIndicator({ + super.key, + this.compact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final heading = ref.watch( + telemetryStreamProvider.select((s) => s.value?.heading ?? 0.0), + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardinalColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + final degreesColor = isDark ? Colors.white70 : AppColors.lightOnSurfaceVariant; + final headingStr = CompassService.getHeadingString(heading); + + if (compact) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.explore_outlined, color: cardinalColor, size: 14), + const SizedBox(width: 4), + Text( + headingStr, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w800, + color: cardinalColor, + ), + ), + const SizedBox(width: 4), + Text( + '${heading.toStringAsFixed(0)}°', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 12, + fontWeight: FontWeight.w700, + color: degreesColor, + ), + ), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + headingStr, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 26, + fontWeight: FontWeight.w900, + color: cardinalColor, + ), + ), + const SizedBox(width: 6), + Text( + '${heading.toStringAsFixed(0)}°', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 16, + fontWeight: FontWeight.w700, + color: degreesColor, + ), + ), + ], + ); + } +} diff --git a/lib/features/dashboard/widgets/lean_angle_indicator.dart b/lib/features/dashboard/widgets/lean_angle_indicator.dart new file mode 100644 index 0000000..d07eb31 --- /dev/null +++ b/lib/features/dashboard/widgets/lean_angle_indicator.dart @@ -0,0 +1,187 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/telemetry_service.dart'; +import '../../rides/ride_state_machine.dart'; + +/// Isolated leaf visualizer for motorcycle lean angle. +/// +/// Subscribes strictly to lean angle changes via [telemetryStreamProvider] +/// and max session values via [rideStateMachineProvider]. +class LeanAngleIndicator extends ConsumerWidget { + final bool compact; + + const LeanAngleIndicator({ + super.key, + this.compact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final leanAngle = ref.watch( + telemetryStreamProvider.select((s) => s.value?.leanAngle ?? 0.0), + ); + final maxValues = ref.watch( + rideStateMachineProvider.select( + (s) => (maxLeft: s.maxLeanLeft, maxRight: s.maxLeanRight), + ), + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + final primaryAccent = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + final secondaryAccent = isDark ? AppColors.warningAmber : AppColors.darkAmber; + + final String direction; + if (leanAngle > 1.0) { + direction = 'RIGHT'; + } else if (leanAngle < -1.0) { + direction = 'LEFT'; + } else { + direction = 'UPRIGHT'; + } + + final dialSize = compact ? 80.0 : 104.0; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'LEAN ANGLE', + style: TextStyle( + fontFamily: AppTypography.fontBody, + color: isDark ? Colors.white54 : AppColors.lightOnSurfaceVariant, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + SizedBox(height: compact ? 2 : 4), + SizedBox( + width: dialSize, + height: dialSize, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(dialSize, dialSize), + painter: _LeanArcPainter( + leanAngle: leanAngle, + accentColor: primaryAccent, + isDark: isDark, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '°', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 20 : 26, + fontWeight: FontWeight.w900, + color: isDark ? Colors.white : AppColors.lightOnSurface, + ), + ), + Text( + direction, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: compact ? 8 : 10, + fontWeight: FontWeight.w800, + color: leanAngle.abs() > 1.0 ? primaryAccent : (isDark ? Colors.white38 : AppColors.lightOnSurfaceVariant), + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: compact ? 2 : 4), + FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'L: ${maxValues.maxLeft.toStringAsFixed(0)}°', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w700, + color: secondaryAccent, + ), + ), + const SizedBox(width: 10), + Text( + 'R: ${maxValues.maxRight.toStringAsFixed(0)}°', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w700, + color: secondaryAccent, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _LeanArcPainter extends CustomPainter { + final double leanAngle; + final Color accentColor; + final bool isDark; + + _LeanArcPainter({ + required this.leanAngle, + required this.accentColor, + required this.isDark, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 4; + + final trackPaint = Paint() + ..color = isDark ? Colors.white12 : Colors.black12 + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + math.pi * 0.75, + math.pi * 1.5, + false, + trackPaint, + ); + + // Indicator sweep + final clampedLean = leanAngle.clamp(-60.0, 60.0); + final sweepAngle = (clampedLean / 60.0) * (math.pi * 0.75); + + final sweepPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 4.0; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + sweepPaint, + ); + } + + @override + bool shouldRepaint(_LeanArcPainter oldDelegate) => + oldDelegate.leanAngle != leanAngle || + oldDelegate.accentColor != accentColor || + oldDelegate.isDark != isDark; +} diff --git a/lib/features/dashboard/widgets/metric_tile.dart b/lib/features/dashboard/widgets/metric_tile.dart new file mode 100644 index 0000000..ccb25ce --- /dev/null +++ b/lib/features/dashboard/widgets/metric_tile.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; + +/// Standardized high-contrast metric card/tile for rider dashboard. +class MetricTile extends StatelessWidget { + final String label; + final String value; + final String? unit; + final IconData? icon; + final bool compact; + + const MetricTile({ + super.key, + required this.label, + required this.value, + this.unit, + this.icon, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final labelColor = isDark ? Colors.white54 : AppColors.lightOnSurfaceVariant; + final valueColor = isDark ? Colors.white : AppColors.lightOnSurface; + final unitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: compact ? 11 : 13, color: labelColor), + const SizedBox(width: 4), + ], + Text( + label.toUpperCase(), + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: compact ? 9 : 11, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + color: labelColor, + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 16 : 20, + fontWeight: FontWeight.w800, + color: valueColor, + ), + ), + if (unit != null) ...[ + const SizedBox(width: 3), + Text( + unit!, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: compact ? 10 : 12, + fontWeight: FontWeight.w700, + color: unitColor, + ), + ), + ], + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/performance_summary.dart b/lib/features/dashboard/widgets/performance_summary.dart new file mode 100644 index 0000000..31c8321 --- /dev/null +++ b/lib/features/dashboard/widgets/performance_summary.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../performance/performance_timer_service.dart'; + +/// Performance run summary chip / card (0-60 and 0-100 timing). +class PerformanceSummary extends ConsumerWidget { + final bool compact; + + const PerformanceSummary({ + super.key, + this.compact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final perfData = ref.watch(performanceTimerProvider); + final lastResult = perfData.lastResult; + final isDark = Theme.of(context).brightness == Brightness.dark; + + if (perfData.state == PerformanceState.timing) { + final elapsed = perfData.currentElapsedSeconds ?? 0.0; + final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.telemetryGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.telemetryGreen, width: 1.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14), + const SizedBox(width: 5), + Text( + '$targetStr: ${elapsed.toStringAsFixed(2)}s', + style: const TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.telemetryGreen, + ), + ), + ], + ), + ); + } + + if (lastResult == null) { + return const SizedBox.shrink(); + } + + final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark ? AppColors.darkOutline : AppColors.lightOutline, + width: 0.8, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber), + const SizedBox(width: 4), + Text( + '$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white70 : AppColors.lightOnSurface, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart new file mode 100644 index 0000000..e6e7367 --- /dev/null +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -0,0 +1,650 @@ +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 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) ...[ + const Spacer(flex: 2), + const SpeedReadout(size: SpeedReadoutSize.hero), + const Spacer(flex: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + 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: 'STATUS', + value: session.state.name.toUpperCase(), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const RideActionButton(), + ] else if (mode == DashboardLayout.touring) ...[ + const SizedBox(height: 6), + const SpeedReadout(size: SpeedReadoutSize.compact), + const SizedBox(height: 8), + const Expanded( + flex: 5, + child: DashboardMapPanel(), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 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, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + const RideActionButton(), + ] else ...[ + // Sport Cockpit Mode + const Spacer(flex: 1), + const SpeedReadout(size: SpeedReadoutSize.hero), + const Spacer(flex: 1), + const Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded(child: GForceIndicator()), + Expanded(child: LeanAngleIndicator()), + ], + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 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, + ), + ), + ), + Expanded( + child: Center( + child: MetricTile( + label: 'TOP SPD', + value: session.topSpeedMph.toStringAsFixed(0), + unit: 'MPH', + icon: Icons.speed_outlined, + ), + ), + ), + Expanded( + child: Center( + child: MetricTile( + label: 'AVG SPD', + value: session.averageSpeedMph.toStringAsFixed(1), + unit: 'MPH', + icon: Icons.av_timer_outlined, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + const RideActionButton(), + ], + ], + ); + } + + // -------------------------------------------------------------------------- + // 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, + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/ride_action_button.dart b/lib/features/dashboard/widgets/ride_action_button.dart new file mode 100644 index 0000000..7f6db17 --- /dev/null +++ b/lib/features/dashboard/widgets/ride_action_button.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../rides/ride_state_machine.dart'; + +/// Primary start/stop recording control button for the rider dashboard. +/// +/// Subscribes strictly to [rideStateMachineProvider.select((s) => s.state)] +/// to prevent rebuilding on high-frequency telemetry samples. +class RideActionButton extends ConsumerWidget { + final bool compact; + + const RideActionButton({ + super.key, + this.compact = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch( + rideStateMachineProvider.select((s) => s.state), + ); + + final isDark = Theme.of(context).brightness == Brightness.dark; + final isRiding = state == RideState.riding; + + final Color buttonColor; + final String label; + final IconData icon; + + if (isRiding) { + buttonColor = AppColors.raceRed; + label = 'STOP SESSION'; + icon = Icons.stop_circle_outlined; + } else { + buttonColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + label = 'START SESSION'; + icon = Icons.play_circle_outlined; + } + + return SizedBox( + width: double.infinity, + height: compact ? 48.0 : 56.0, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: buttonColor.withValues(alpha: isRiding ? 0.16 : 0.12), + foregroundColor: buttonColor, + side: BorderSide(color: buttonColor, width: 2.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + onPressed: () { + final notifier = ref.read(rideStateMachineProvider.notifier); + if (isRiding) { + notifier.finishRide(); + } else { + notifier.startRide(); + } + }, + icon: Icon(icon, size: compact ? 20 : 24, color: buttonColor), + label: Text( + label, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: compact ? 15 : 18, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/ride_status_chip.dart b/lib/features/dashboard/widgets/ride_status_chip.dart new file mode 100644 index 0000000..f7de2c1 --- /dev/null +++ b/lib/features/dashboard/widgets/ride_status_chip.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../rides/ride_state_machine.dart'; + +/// Authoritative ride status badge/chip. +/// +/// Subscribes strictly to [rideStateMachineProvider.select((s) => s.state)] +/// to prevent rebuilding on 20 Hz telemetry ticks. +class RideStatusChip extends ConsumerWidget { + const RideStatusChip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch( + rideStateMachineProvider.select((s) => s.state), + ); + + final isRecording = state == RideState.riding; + final isPaused = state == RideState.paused; + + final Color badgeColor; + final String label; + + if (isRecording) { + badgeColor = AppColors.raceRed; + label = 'RECORDING'; + } else if (isPaused) { + badgeColor = AppColors.warningAmber; + label = 'PAUSED'; + } else if (state == RideState.ready) { + badgeColor = AppColors.cyberCyan; + label = 'READY'; + } else { + badgeColor = Colors.white54; + label = 'STANDBY'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: isRecording ? 0.18 : 0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: badgeColor.withValues(alpha: isRecording ? 0.8 : 0.35), + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: badgeColor, + boxShadow: isRecording + ? [ + BoxShadow( + color: badgeColor.withValues(alpha: 0.6), + blurRadius: 6, + ), + ] + : null, + ), + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 10, + fontWeight: FontWeight.w800, + color: badgeColor, + letterSpacing: 0.6, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/widgets/speed_readout.dart b/lib/features/dashboard/widgets/speed_readout.dart new file mode 100644 index 0000000..207cd8e --- /dev/null +++ b/lib/features/dashboard/widgets/speed_readout.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../services/telemetry_service.dart'; + +/// Structured telemetry observation model for speedometer pipeline. +/// +/// Designed so future forensic instrumentation can observe raw GNSS speed, +/// validated speed, filtered speed, and displayed speed without rewriting UI. +class SpeedTelemetryObservation { + final double rawGnssSpeedMph; + final double validatedSpeedMph; + final double filteredSpeedMph; + final double displayedSpeedMph; + + const SpeedTelemetryObservation({ + required this.rawGnssSpeedMph, + required this.validatedSpeedMph, + required this.filteredSpeedMph, + required this.displayedSpeedMph, + }); +} + +enum SpeedReadoutSize { + compact, + regular, + hero, +} + +/// Authoritative isolated leaf widget for current speed readout. +/// +/// Subscribes strictly to speed changes via [telemetryStreamProvider] +/// to prevent outer dashboard reconstructions at 20 Hz. +class SpeedReadout extends ConsumerWidget { + final SpeedReadoutSize size; + final String unit; + final void Function(SpeedTelemetryObservation observation)? onObservation; + + const SpeedReadout({ + super.key, + this.size = SpeedReadoutSize.regular, + this.unit = 'MPH', + this.onObservation, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speed = ref.watch( + telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0), + ); + + final clampedSpeed = speed < 0.0 ? 0.0 : speed; + + if (onObservation != null) { + onObservation!(SpeedTelemetryObservation( + rawGnssSpeedMph: clampedSpeed, + validatedSpeedMph: clampedSpeed, + filteredSpeedMph: clampedSpeed, + displayedSpeedMph: clampedSpeed, + )); + } + + final isDark = Theme.of(context).brightness == Brightness.dark; + final speedColor = isDark ? Colors.white : AppColors.lightOnSurface; + final unitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary; + + final (double fontSize, double unitSize) = switch (size) { + SpeedReadoutSize.compact => (54.0, 14.0), + SpeedReadoutSize.regular => (96.0, 18.0), + SpeedReadoutSize.hero => (140.0, 24.0), + }; + + return RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + clampedSpeed.toStringAsFixed(0), + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: fontSize, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + height: 0.95, + color: speedColor, + shadows: isDark + ? [ + Shadow( + color: AppColors.cyberCyan.withValues(alpha: 0.4), + blurRadius: 16, + ), + ] + : null, + ), + ), + ), + const SizedBox(height: 2), + Text( + unit, + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: unitSize, + fontWeight: FontWeight.w800, + fontStyle: FontStyle.italic, + letterSpacing: 2.5, + color: unitColor, + ), + ), + ], + ), + ); + } +} diff --git a/test/canonical_dashboard_responsive_test.dart b/test/canonical_dashboard_responsive_test.dart new file mode 100644 index 0000000..f1b6589 --- /dev/null +++ b/test/canonical_dashboard_responsive_test.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/features/dashboard/dashboard_config.dart'; +import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_map_panel.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/g_force_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/heading_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/lean_angle_indicator.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/ride_action_button.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/ride_status_chip.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:motorcycle_dash/services/telemetry_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SharedPreferences prefs; + late StreamController telemetryController; + late StreamController> alertsController; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + telemetryController = StreamController.broadcast(); + alertsController = StreamController>.broadcast(); + }); + + tearDown(() async { + await telemetryController.close(); + await alertsController.close(); + }); + + Widget buildTestDashboard({ + ThemeMode themeMode = ThemeMode.dark, + RideSession? initialSession, + DashboardLayout initialLayout = DashboardLayout.sport, + bool hasLocationPermission = true, + }) { + return ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), + activeAlertsStreamProvider.overrideWith((ref) => alertsController.stream), + locationPermissionGrantedProvider.overrideWith( + (ref) => hasLocationPermission, + ), + rideStateMachineProvider.overrideWith( + (ref) => _TestRideStateMachine(initialSession), + ), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: const DashboardScreen(), + ), + ); + } + + void configureViewport(WidgetTester tester, double width, double height) { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1.0; + } + + group('Canonical Dashboard Viewport Matrix Tests', () { + final viewports = { + '320x640 (Small Compact Phone)': const Size(320, 640), + '400x800 (Standard Phone Portrait)': const Size(400, 800), + '412x915 (Pixel-class Portrait)': const Size(412, 915), + '780x360 (Short Landscape Mount)': const Size(780, 360), + '800x360 (Cockpit Mount)': const Size(800, 360), + '840x390 (Modern Landscape Phone)': const Size(840, 390), + '600x900 (Medium Tablet Portrait)': const Size(600, 900), + '840x600 (Medium Tablet Landscape)': const Size(840, 600), + '1024x768 (Large Tablet Landscape)': const Size(1024, 768), + }; + + for (final entry in viewports.entries) { + testWidgets('Renders cleanly on ${entry.key}', (tester) async { + configureViewport(tester, entry.value.width, entry.value.height); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(tester.takeException(), isNull, reason: 'Zero RenderFlex overflow on ${entry.key}'); + expect(find.byType(SpeedReadout), findsOneWidget); + expect(find.byType(HeadingIndicator), findsOneWidget); + expect(find.byType(GPSStatusIndicator), findsOneWidget); + expect(find.byType(RideStatusChip), findsOneWidget); + expect(find.byType(RideActionButton), findsOneWidget); + }); + } + }); + + group('Dashboard Theme Tests', () { + testWidgets('Renders properly in Light Mode', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard(themeMode: ThemeMode.light)); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(SpeedReadout), findsOneWidget); + expect(find.text('START SESSION'), findsOneWidget); + }); + + testWidgets('Renders properly in Dark Mode', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard(themeMode: ThemeMode.dark)); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(SpeedReadout), findsOneWidget); + expect(find.text('START SESSION'), findsOneWidget); + }); + }); + + group('Dashboard Layout Modes (Sport, Touring, Minimal)', () { + testWidgets('Sport Mode displays Lean and G-Force indicators', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(find.byType(GForceIndicator), findsOneWidget); + expect(find.byType(LeanAngleIndicator), findsOneWidget); + }); + + testWidgets('Minimal Mode displays Speedometer and minimal controls', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await prefs.setString('dashboardLayout', 'minimal'); + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(find.byType(SpeedReadout), findsOneWidget); + expect(find.byType(GForceIndicator), findsNothing); + expect(find.byType(LeanAngleIndicator), findsNothing); + expect(find.byType(RideActionButton), findsOneWidget); + }); + + testWidgets('Touring Mode is mode-driven: displays map in both portrait and landscape', (tester) async { + await prefs.setString('dashboardLayout', 'touring'); + + // Portrait + configureViewport(tester, 400, 800); + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in portrait Touring'); + expect(find.text('MOVING'), findsOneWidget); + + // Landscape + configureViewport(tester, 800, 360); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(DashboardMapPanel), findsOneWidget, reason: 'Map present in landscape Touring'); + }); + }); + + group('Performance & Rebuild Isolation Tests', () { + testWidgets('Telemetry updates speed readout without destroying ride state', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + expect(find.text('0'), findsWidgets); + + // Emit high-frequency sample + telemetryController.add( + TelemetrySample( + timestamp: DateTime.now(), + speed: 68.4, + heading: 270.0, + leanAngle: 24.5, + longitudinalG: 0.42, + lateralG: 0.35, + gpsAccuracy: 9.0, + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('68'), findsOneWidget); + expect(find.text('W'), findsOneWidget); + expect(find.text('270°'), findsOneWidget); + expect(find.text('GPS 3D FIX'), findsOneWidget); + }); + + testWidgets('Resizing viewport preserves active ride session', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + // Start ride + await tester.tap(find.byType(RideActionButton)); + await tester.pump(); + + expect(find.text('STOP SESSION'), findsOneWidget); + expect(find.text('RECORDING'), findsOneWidget); + + // Rotate to landscape + configureViewport(tester, 800, 360); + await tester.pump(); + + // Verify ride did NOT stop or reset + expect(find.text('STOP SESSION'), findsOneWidget); + expect(find.text('RECORDING'), findsOneWidget); + }); + + testWidgets('Alert region displays alert banner and handles dismissal', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard()); + await tester.pump(); + + alertsController.add([ + RoadAlert( + id: 'test_alert_1', + providerId: 'here_maps', + category: RoadAlertCategory.hazard, + latitude: 37.77, + longitude: -122.42, + distanceFromUser: 250, + roadName: 'Skyline Blvd', + description: 'Obstacle in road', + reportedAt: DateTime.now(), + lastUpdated: DateTime.now(), + source: 'here', + ), + ]); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('HAZARD'), findsOneWidget); + expect(find.text('250m Ahead • Skyline Blvd'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('Displays location permission banner when permission denied', (tester) async { + configureViewport(tester, 400, 800); + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(buildTestDashboard(hasLocationPermission: false)); + await tester.pump(); + + expect(find.text('Location permission needed for GPS speedometer.'), findsOneWidget); + expect(find.text('Enable'), findsOneWidget); + }); + }); +} + +class _TestRideStateMachine extends StateNotifier + implements RideStateMachine { + _TestRideStateMachine([RideSession? initial]) + : super( + initial ?? + RideSession( + state: RideState.idle, + startTime: DateTime.now(), + ), + ); + + @override + Future startRide() async { + state = state.copyWith( + state: RideState.riding, + startTime: DateTime.now(), + tripDistanceMiles: 0.0, + topSpeedMph: 0.0, + averageSpeedMph: 0.0, + ); + } + + @override + Future finishRide() async { + state = state.copyWith( + state: RideState.finished, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}