diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index 288387b..74df6d0 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -4,6 +4,8 @@ import 'package:drift/drift.dart' as drift; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; import '../../database/database.dart'; +import '../../core/responsive/responsive_layout.dart'; +import '../../core/theme/theme_extensions.dart'; import '../../services/location_service.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_providers.dart'; @@ -505,121 +507,326 @@ class _MapScreenState extends ConsumerState { @override Widget build(BuildContext context) { - if (_isLoadingHistory) return const Scaffold(body: Center(child: CircularProgressIndicator())); + if (_isLoadingHistory) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } final activeProviderType = ref.watch(mapProviderTypeNotifierProvider); final isFollowMode = ref.watch(mapFollowStateProvider); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final colors = context.motorColors; + final theme = Theme.of(context); - if (widget.rideId != null) { - final polylines = _buildPolylines(); - return Scaffold( - appBar: AppBar( - title: Text('Ride #${widget.rideId} Multi-Layer Map'), - actions: [IconButton( - tooltip: 'Switch Map Provider', - icon: Icon(activeProviderType == AppMapType.openStreetMap ? Icons.map : Icons.map_outlined), - onPressed: () { - final nextType = activeProviderType == AppMapType.openStreetMap ? AppMapType.googleMaps : AppMapType.openStreetMap; - ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(nextType); - }, - )], - ), - body: LayoutBuilder(builder: (context, constraints) => Stack(fit: StackFit.expand, children: [ - Positioned.fill(child: SizedBox( - width: constraints.maxWidth, height: constraints.maxHeight, - child: UnifiedMapView( - key: ValueKey('preview_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), - center: _initialCenter ?? _fallbackLocation, zoom: 15.0, - polylines: polylines, markers: _getFilteredMarkers(), + return ResponsiveLayout( + builder: (context, responsive) { + final isShortLandscape = responsive.isLandscape && responsive.height < 450; + + if (widget.rideId != null) { + final polylines = _buildPolylines(); + return Scaffold( + appBar: AppBar( + title: Text('Ride #${widget.rideId} Multi-Layer Map'), + actions: [ + IconButton( + tooltip: 'Switch Map Provider', + icon: Icon( + activeProviderType == AppMapType.openStreetMap + ? Icons.map + : Icons.map_outlined, + ), + onPressed: () { + final nextType = + activeProviderType == AppMapType.openStreetMap + ? AppMapType.googleMaps + : AppMapType.openStreetMap; + ref + .read(mapProviderTypeNotifierProvider.notifier) + .setMapProviderType(nextType); + }, + ), + ], ), - )), - if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(), - if (_showSpeedHeatmap) _buildSpeedLegend(), - if (_routePoints.isEmpty) Positioned(top: 16, left: 20, right: 20, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration(color: const Color(0xFF0D0D0D).withValues(alpha: 0.9), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.4))), - child: const Row(children: [Icon(Icons.info_outline, color: Color(0xFF00E5FF), size: 20), SizedBox(width: 10), Expanded(child: Text('No GPS breadcrumbs were recorded during this test session.', style: TextStyle(color: Colors.white70, fontSize: 12, fontFamily: 'Inter')))]), - )), - ])), - ); - } + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: UnifiedMapView( + key: ValueKey('preview_map_${widget.rideId}'), + center: _initialCenter ?? _fallbackLocation, + zoom: 15.0, + polylines: polylines, + markers: _getFilteredMarkers(), + ), + ), + if (_routePoints.isNotEmpty) _buildMultiLayerFilterOverlay(colors), + if (_showSpeedHeatmap) _buildSpeedLegend(colors), + if (_routePoints.isEmpty) + Positioned( + top: 16, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: colors.surfaceContainer.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.cyberCyan.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: colors.cyberCyan, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'No GPS breadcrumbs were recorded during this test session.', + style: TextStyle( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: 12, + fontFamily: 'Inter', + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } - final locationAsync = ref.watch(locationStreamProvider); - final liveAlertsAsync = ref.watch(activeAlertsStreamProvider); - final currentPos = locationAsync.when(data: (pos) => AppLatLng(pos.latitude, pos.longitude), error: (_, _) => _fallbackLocation, loading: () => _fallbackLocation); - final currentHeading = locationAsync.when(data: (pos) => pos.heading, error: (_, _) => 0.0, loading: () => 0.0); - final motorcycleMarker = AppMapMarker(id: 'motorcycle_live', position: currentPos, heading: currentHeading, iconWidget: const Icon(Icons.navigation, color: Colors.cyanAccent, size: 40)); - final List mapMarkers = [motorcycleMarker]; - final liveAlerts = liveAlertsAsync.value ?? []; - for (var alert in liveAlerts) { - mapMarkers.add(AppMapMarker( - id: '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(6), 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: 22)), - onTap: () => _showRoadAlertDetails(alert), - )); - } - return Scaffold( - extendBodyBehindAppBar: isLandscape, - appBar: AppBar( - title: const Text('Live Map'), backgroundColor: isLandscape ? Colors.black.withValues(alpha: 0.6) : null, elevation: isLandscape ? 0 : null, - actions: [PopupMenuButton( - icon: const Icon(Icons.layers), - onSelected: (type) => ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(type), - itemBuilder: (context) => const [ - PopupMenuItem(value: AppMapType.openStreetMap, child: Text('OpenStreetMap / Tactical (Primary)')), - PopupMenuItem(value: AppMapType.googleMaps, child: Text('Google Maps')), - ], - )], - ), - body: LayoutBuilder(builder: (context, constraints) => Stack(fit: StackFit.expand, children: [ - Positioned.fill(child: SizedBox(width: constraints.maxWidth, height: constraints.maxHeight, child: UnifiedMapView( - key: ValueKey('live_map_${constraints.maxWidth.toInt()}x${constraints.maxHeight.toInt()}'), center: currentPos, zoom: 17.0, markers: mapMarkers, - ))), - if (!isFollowMode) Positioned(bottom: 24, right: 24, child: FloatingActionButton.extended( - backgroundColor: Colors.cyanAccent, foregroundColor: Colors.black, icon: const Icon(Icons.my_location), label: const Text('RECENTER', style: TextStyle(fontWeight: FontWeight.bold)), - onPressed: () => ref.read(mapFollowStateProvider.notifier).recenter(), - )), - ])), + final locationAsync = ref.watch(locationStreamProvider); + final liveAlertsAsync = ref.watch(activeAlertsStreamProvider); + final currentPos = locationAsync.when( + data: (pos) => AppLatLng(pos.latitude, pos.longitude), + error: (_, _) => _fallbackLocation, + loading: () => _fallbackLocation, + ); + final currentHeading = locationAsync.when( + data: (pos) => pos.heading, + error: (_, _) => 0.0, + loading: () => 0.0, + ); + final motorcycleMarker = AppMapMarker( + id: 'motorcycle_live', + position: currentPos, + heading: currentHeading, + iconWidget: Icon( + Icons.navigation, + color: colors.cyberCyan, + size: 40, + ), + ); + final List mapMarkers = [motorcycleMarker]; + final liveAlerts = liveAlertsAsync.value ?? []; + for (var alert in liveAlerts) { + mapMarkers.add( + AppMapMarker( + id: '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(6), + 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: 22, + ), + ), + onTap: () => _showRoadAlertDetails(alert), + ), + ); + } + + return Scaffold( + extendBodyBehindAppBar: isShortLandscape, + appBar: AppBar( + title: const Text('Live Map'), + backgroundColor: isShortLandscape + ? colors.surfaceContainer.withValues(alpha: 0.75) + : null, + elevation: isShortLandscape ? 0 : null, + actions: [ + PopupMenuButton( + icon: const Icon(Icons.layers), + onSelected: (type) => ref + .read(mapProviderTypeNotifierProvider.notifier) + .setMapProviderType(type), + itemBuilder: (context) => const [ + PopupMenuItem( + value: AppMapType.openStreetMap, + child: Text('OpenStreetMap / Tactical (Primary)'), + ), + PopupMenuItem( + value: AppMapType.googleMaps, + child: Text('Google Maps'), + ), + ], + ), + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: UnifiedMapView( + key: const ValueKey('live_map_view'), + center: currentPos, + zoom: 17.0, + markers: mapMarkers, + ), + ), + if (!isFollowMode) + Positioned( + bottom: 24, + right: 24, + child: FloatingActionButton.extended( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + icon: const Icon(Icons.my_location), + label: const Text( + 'RECENTER', + style: TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: () => + ref.read(mapFollowStateProvider.notifier).recenter(), + ), + ), + ], + ), + ); + }, ); } - Widget _buildMultiLayerFilterOverlay() { - return Positioned(top: 10, left: 10, right: 10, child: Card( - color: Colors.black.withValues(alpha: 0.8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: const BorderSide(color: Color(0xFF1F1F1F))), - child: Padding(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: [ - FilterChip(label: const Text('Route'), selected: _showRoute, onSelected: (v) => setState(() => _showRoute = v), selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35)), - const SizedBox(width: 6), - FilterChip(label: const Text('Speed'), selected: _showSpeedHeatmap, onSelected: (v) => setState(() => _showSpeedHeatmap = v), selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35)), - const SizedBox(width: 6), - FilterChip(label: const Text('Accel'), selected: _showHardAccel, onSelected: (v) => setState(() => _showHardAccel = v), selectedColor: const Color(0xFF76FF03).withValues(alpha: 0.35)), - const SizedBox(width: 6), - FilterChip(label: const Text('Braking'), selected: _showHardBraking, onSelected: (v) => setState(() => _showHardBraking = v), selectedColor: const Color(0xFFFF5252).withValues(alpha: 0.35)), - const SizedBox(width: 6), - FilterChip(label: const Text('Cornering'), selected: _showCornering, onSelected: (v) => setState(() => _showCornering = v), selectedColor: const Color(0xFF00E5FF).withValues(alpha: 0.35)), - ]))), - )); + Widget _buildMultiLayerFilterOverlay(MotorSignalThemeColors colors) { + return Positioned( + top: 10, + left: 10, + right: 10, + child: Card( + color: colors.surfaceContainer.withValues(alpha: 0.85), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: const Text('Route'), + selected: _showRoute, + onSelected: (v) => setState(() => _showRoute = v), + selectedColor: colors.cyberCyan.withValues(alpha: 0.35), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Speed'), + selected: _showSpeedHeatmap, + onSelected: (v) => setState(() => _showSpeedHeatmap = v), + selectedColor: const Color(0xFFE040FB).withValues(alpha: 0.35), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Accel'), + selected: _showHardAccel, + onSelected: (v) => setState(() => _showHardAccel = v), + selectedColor: colors.launchGreen.withValues(alpha: 0.35), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Braking'), + selected: _showHardBraking, + onSelected: (v) => setState(() => _showHardBraking = v), + selectedColor: colors.raceRed.withValues(alpha: 0.35), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Cornering'), + selected: _showCornering, + onSelected: (v) => setState(() => _showCornering = v), + selectedColor: colors.cyberCyan.withValues(alpha: 0.35), + ), + ], + ), + ), + ), + ), + ); } - Widget _buildSpeedLegend() => Positioned(bottom: 20, left: 14, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF222222))), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text('SPEED RANGES (MPH)', style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 8.5, fontWeight: FontWeight.bold, color: Colors.white70)), - const SizedBox(height: 4), - _buildLegendItem('0–25', const Color(0xFF00E5FF)), - _buildLegendItem('25–50', const Color(0xFF2979FF)), - _buildLegendItem('50–75', const Color(0xFFFFD600)), - _buildLegendItem('75–100', const Color(0xFFFF6D00)), - _buildLegendItem('100+', const Color(0xFFE040FB)), - ]), - )); + Widget _buildSpeedLegend(MotorSignalThemeColors colors) => Positioned( + bottom: 20, + left: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainer.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'SPEED RANGES (MPH)', + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 8.5, + fontWeight: FontWeight.bold, + color: colors.cyberCyan, + ), + ), + const SizedBox(height: 4), + _buildLegendItem('0–25', const Color(0xFF00E5FF)), + _buildLegendItem('25–50', const Color(0xFF2979FF)), + _buildLegendItem('50–75', const Color(0xFFFFD600)), + _buildLegendItem('75–100', const Color(0xFFFF6D00)), + _buildLegendItem('100+', const Color(0xFFE040FB)), + ], + ), + ), + ); Widget _buildLegendItem(String label, Color color) => Padding( - padding: const EdgeInsets.symmetric(vertical: 1.5), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 6), - Text(label, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 9, color: Colors.white)), - ]), - ); + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); } diff --git a/lib/features/routes/route_detail_screen.dart b/lib/features/routes/route_detail_screen.dart index 139527a..f23b174 100644 --- a/lib/features/routes/route_detail_screen.dart +++ b/lib/features/routes/route_detail_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../core/responsive/responsive_layout.dart'; +import '../../core/responsive/viewport_size.dart'; +import '../../core/theme/theme_extensions.dart'; import '../../services/auth/auth_providers.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; @@ -21,13 +24,14 @@ class _RouteDetailScreenState extends ConsumerState { bool _isDeleting = false; Future _confirmDelete(MotorSignalRoute route) async { + final colors = context.motorColors; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF13151A), + backgroundColor: colors.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFF2A2E3D)), + side: BorderSide(color: colors.outlineVariant), ), title: const Text( 'Delete Route', @@ -47,7 +51,7 @@ class _RouteDetailScreenState extends ConsumerState { ), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFFF5252), + backgroundColor: colors.raceRed, foregroundColor: Colors.white, ), onPressed: () => Navigator.pop(ctx, true), @@ -114,13 +118,14 @@ class _RouteDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final colors = context.motorColors; final routeAsync = ref.watch(routeByIdProvider(widget.routeId)); final currentUser = ref.watch(currentUserProvider); return Scaffold( - backgroundColor: const Color(0xFF0D0E11), + backgroundColor: colors.surfaceContainer, appBar: AppBar( - backgroundColor: const Color(0xFF13151A), + backgroundColor: colors.surfaceContainerHigh, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), @@ -136,8 +141,8 @@ class _RouteDetailScreenState extends ConsumerState { ), ), body: routeAsync.when( - loading: () => const Center( - child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + loading: () => Center( + child: CircularProgressIndicator(color: colors.cyberCyan), ), error: (err, _) => Center( child: Padding( @@ -145,10 +150,10 @@ class _RouteDetailScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.error_outline, size: 48, - color: Color(0xFFFF5252), + color: colors.raceRed, ), const SizedBox(height: 12), Text( @@ -187,10 +192,10 @@ class _RouteDetailScreenState extends ConsumerState { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isFirst - ? const Color(0xFF00E676) + ? colors.launchGreen : isLast - ? const Color(0xFFFF5252) - : const Color(0xFF00E5FF), + ? colors.raceRed + : colors.cyberCyan, shape: BoxShape.circle, boxShadow: const [ BoxShadow( @@ -228,7 +233,7 @@ class _RouteDetailScreenState extends ConsumerState { points: route.previewPoints .map((p) => AppLatLng(p.latitude, p.longitude)) .toList(), - color: const Color(0xFF00E5FF), + color: colors.cyberCyan, width: 3.5, ), ] @@ -241,421 +246,579 @@ class _RouteDetailScreenState extends ConsumerState { ) : const AppLatLng(37.7749, -122.4194); - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Map Preview - SizedBox( - height: 240, - width: double.infinity, - child: Stack( - children: [ - UnifiedMapView( - center: mapCenter, - zoom: 12.0, - markers: markers, - polylines: polylines, - ), - Positioned( - top: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color( - 0xFF13151A, - ).withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFF2A2E3D)), - ), - child: Text( - route.visibility.label, - style: const TextStyle( - color: Color(0xFF00E5FF), - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), + return ResponsiveLayout( + builder: (context, snapshot) { + final isExpandedLayout = + snapshot.sizeClass == WindowSizeClass.expanded || + (snapshot.isLandscape && snapshot.width >= 720); - // Title & Info - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - route.title, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - if (route.description.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - route.description, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - ], - const SizedBox(height: 12), + if (isExpandedLayout) { + return _buildExpandedLayout( + context, + colors, + route, + isOwner, + mapCenter, + markers, + polylines, + ); + } - // Distance and Stops Badges - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF13151A), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF2A2E3D)), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'STRAIGHT-LINE DISTANCE', - style: TextStyle( - color: Colors.white38, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, - ), - ), - const SizedBox(height: 2), - Text( - '${route.formattedDistanceMiles} (${route.formattedDistanceKm})', - style: const TextStyle( - color: Color(0xFF00E5FF), - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - Container( - width: 1, - height: 32, - color: const Color(0xFF2A2E3D), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'WAYPOINTS', - style: TextStyle( - color: Colors.white38, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, - ), - ), - const SizedBox(height: 2), - Text( - '${route.stops.length} STOPS', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 8), - - // Honest Disclaimer - const Text( - 'MotorSignal provides straight-line route planning and handoff. Turn-by-turn navigation opens in Google Maps or Waze.', - style: TextStyle(color: Colors.white38, fontSize: 11), - ), - const SizedBox(height: 16), - - // Owner Actions: Edit & Delete - if (isOwner) ...[ - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - key: const ValueKey('route_detail_edit_button'), - onPressed: () { - context.push( - '/routes/planner?routeId=${route.id}', - ); - }, - icon: const Icon( - Icons.edit, - size: 16, - color: Color(0xFF00E5FF), - ), - label: const Text( - 'EDIT ROUTE', - style: TextStyle(color: Color(0xFF00E5FF)), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: Color(0xFF00E5FF), - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - ), - ), - ), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - key: const ValueKey('route_detail_delete_button'), - onPressed: _isDeleting - ? null - : () => _confirmDelete(route), - icon: const Icon( - Icons.delete_outline, - size: 16, - color: Color(0xFFFF5252), - ), - label: const Text( - 'DELETE', - style: TextStyle(color: Color(0xFFFF5252)), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: Color(0xFFFF5252), - ), - padding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - ], - - // Attached Group Ride button if any - if (route.attachedGroupRideId != null) ...[ - OutlinedButton.icon( - key: const ValueKey( - 'route_detail_view_attached_group_ride_button', - ), - onPressed: () { - context.push( - '/group-rides/${route.attachedGroupRideId}', - ); - }, - icon: const Icon( - Icons.group, - size: 18, - color: Color(0xFF00E5FF), - ), - label: const Text( - 'VIEW ATTACHED GROUP RIDE', - style: TextStyle(color: Color(0xFF00E5FF)), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFF2A2E3D)), - minimumSize: const Size(double.infinity, 44), - ), - ), - const SizedBox(height: 16), - ], - - // Stops List Header - const Text( - 'STOPS & WAYPOINTS', - style: TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - ), - ), - const SizedBox(height: 10), - - // Stops List - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: route.stops.length, - itemBuilder: (context, index) { - final stop = route.stops[index]; - final isFirst = index == 0; - final isLast = - index == route.stops.length - 1 && - route.stops.length > 1; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF13151A), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: const Color(0xFF2A2E3D), - ), - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: isFirst - ? const Color(0xFF00E676) - : isLast - ? const Color(0xFFFF5252) - : const Color(0xFF00E5FF), - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - '${index + 1}', - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _getIconForStopType(stop.stopType), - size: 14, - color: const Color(0xFF00E5FF), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - stop.displayName, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', - style: const TextStyle( - color: Colors.white38, - fontSize: 11, - ), - ), - if (stop.notes.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - stop.notes, - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - ElevatedButton.icon( - onPressed: () { - NavigationHandoffService.showHandoffSheet( - context: context, - latitude: stop.latitude, - longitude: stop.longitude, - displayName: stop.displayName, - ); - }, - icon: const Icon(Icons.navigation, size: 14), - label: const Text('NAV'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF), - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 24), - - // Primary action: Navigate to First Stop - if (route.stops.isNotEmpty) - ElevatedButton.icon( - key: const ValueKey( - 'route_detail_navigate_start_button', - ), - onPressed: () { - final firstStop = route.stops.first; - NavigationHandoffService.showHandoffSheet( - context: context, - latitude: firstStop.latitude, - longitude: firstStop.longitude, - displayName: 'Start: ${firstStop.displayName}', - ); - }, - icon: const Icon(Icons.directions, size: 20), - label: const Text( - 'NAVIGATE TO ROUTE START', - style: TextStyle( - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF), - foregroundColor: Colors.black, - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - const SizedBox(height: 32), - ], - ), - ), - ], - ), + return _buildCompactLayout( + context, + colors, + snapshot, + route, + isOwner, + mapCenter, + markers, + polylines, + ); + }, ); }, ), ); } + + Widget _buildExpandedLayout( + BuildContext context, + MotorSignalThemeColors colors, + MotorSignalRoute route, + bool isOwner, + AppLatLng mapCenter, + List markers, + List polylines, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Left Column: Map Preview + Metrics + Expanded( + flex: 5, + child: Container( + decoration: BoxDecoration( + border: Border( + right: BorderSide(color: colors.outlineVariant), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Map Preview Box + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 280, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + key: ValueKey('route_detail_map_exp_${route.id}'), + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + Positioned( + top: 8, + left: 8, + child: _buildVisibilityBadge(colors, route), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + _buildDistanceAndStopsCard(colors, route), + const SizedBox(height: 12), + _buildDisclaimerText(), + ], + ), + ), + ), + ), + + // Right Column: Details, Actions, Stops list + Expanded( + flex: 6, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleAndDescription(colors, route), + const SizedBox(height: 16), + if (isOwner) ...[ + _buildOwnerActions(colors, route), + const SizedBox(height: 16), + ], + if (route.attachedGroupRideId != null) ...[ + _buildAttachedGroupRideButton(colors, route), + const SizedBox(height: 16), + ], + _buildStopsHeader(), + const SizedBox(height: 10), + _buildStopsList(colors, route), + const SizedBox(height: 24), + if (route.stops.isNotEmpty) _buildNavigateStartButton(colors, route), + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ); + } + + Widget _buildCompactLayout( + BuildContext context, + MotorSignalThemeColors colors, + ResponsiveSnapshot snapshot, + MotorSignalRoute route, + bool isOwner, + AppLatLng mapCenter, + List markers, + List polylines, + ) { + final double mapHeight = snapshot.height < 450 ? 140 : 220; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Map Preview + SizedBox( + height: mapHeight, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + key: ValueKey('route_detail_map_compact_${route.id}'), + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + Positioned( + top: 8, + left: 8, + child: _buildVisibilityBadge(colors, route), + ), + ], + ), + ), + + // Title & Info + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitleAndDescription(colors, route), + const SizedBox(height: 12), + _buildDistanceAndStopsCard(colors, route), + const SizedBox(height: 8), + _buildDisclaimerText(), + const SizedBox(height: 16), + if (isOwner) ...[ + _buildOwnerActions(colors, route), + const SizedBox(height: 16), + ], + if (route.attachedGroupRideId != null) ...[ + _buildAttachedGroupRideButton(colors, route), + const SizedBox(height: 16), + ], + _buildStopsHeader(), + const SizedBox(height: 10), + _buildStopsList(colors, route), + const SizedBox(height: 24), + if (route.stops.isNotEmpty) _buildNavigateStartButton(colors, route), + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ); + } + + Widget _buildVisibilityBadge(MotorSignalThemeColors colors, MotorSignalRoute route) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.surfaceContainer.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.outlineVariant), + ), + child: Text( + route.visibility.label, + style: TextStyle( + color: colors.cyberCyan, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildTitleAndDescription(MotorSignalThemeColors colors, MotorSignalRoute route) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + route.title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (route.description.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + route.description, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ], + ); + } + + Widget _buildDistanceAndStopsCard(MotorSignalThemeColors colors, MotorSignalRoute route) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'STRAIGHT-LINE DISTANCE', + style: TextStyle( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + '${route.formattedDistanceMiles} (${route.formattedDistanceKm})', + style: TextStyle( + color: colors.cyberCyan, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Container( + width: 1, + height: 32, + color: colors.outlineVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'WAYPOINTS', + style: TextStyle( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + '${route.stops.length} STOPS', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDisclaimerText() { + return const Text( + 'MotorSignal provides straight-line route planning and handoff. Turn-by-turn navigation opens in Google Maps or Waze.', + style: TextStyle(color: Colors.white38, fontSize: 11), + ); + } + + Widget _buildOwnerActions(MotorSignalThemeColors colors, MotorSignalRoute route) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + key: const ValueKey('route_detail_edit_button'), + onPressed: () { + context.push( + '/routes/planner?routeId=${route.id}', + ); + }, + icon: Icon( + Icons.edit, + size: 16, + color: colors.cyberCyan, + ), + label: Text( + 'EDIT ROUTE', + style: TextStyle(color: colors.cyberCyan), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: colors.cyberCyan, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + ), + ), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + key: const ValueKey('route_detail_delete_button'), + onPressed: _isDeleting ? null : () => _confirmDelete(route), + icon: Icon( + Icons.delete_outline, + size: 16, + color: colors.raceRed, + ), + label: Text( + 'DELETE', + style: TextStyle(color: colors.raceRed), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: colors.raceRed, + ), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + ), + ), + ], + ); + } + + Widget _buildAttachedGroupRideButton(MotorSignalThemeColors colors, MotorSignalRoute route) { + return OutlinedButton.icon( + key: const ValueKey( + 'route_detail_view_attached_group_ride_button', + ), + onPressed: () { + context.push( + '/group-rides/${route.attachedGroupRideId}', + ); + }, + icon: Icon( + Icons.group, + size: 18, + color: colors.cyberCyan, + ), + label: Text( + 'VIEW ATTACHED GROUP RIDE', + style: TextStyle(color: colors.cyberCyan), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.outlineVariant), + minimumSize: const Size(double.infinity, 44), + ), + ); + } + + Widget _buildStopsHeader() { + return const Text( + 'STOPS & WAYPOINTS', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ); + } + + Widget _buildStopsList(MotorSignalThemeColors colors, MotorSignalRoute route) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: route.stops.length, + itemBuilder: (context, index) { + final stop = route.stops[index]; + final isFirst = index == 0; + final isLast = + index == route.stops.length - 1 && + route.stops.length > 1; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colors.outlineVariant, + ), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colors.launchGreen + : isLast + ? colors.raceRed + : colors.cyberCyan, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getIconForStopType(stop.stopType), + size: 14, + color: colors.cyberCyan, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + stop.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + if (stop.notes.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + stop.notes, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ElevatedButton.icon( + onPressed: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + icon: const Icon(Icons.navigation, size: 14), + label: const Text('NAV'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildNavigateStartButton(MotorSignalThemeColors colors, MotorSignalRoute route) { + return ElevatedButton.icon( + key: const ValueKey( + 'route_detail_navigate_start_button', + ), + onPressed: () { + final firstStop = route.stops.first; + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: firstStop.latitude, + longitude: firstStop.longitude, + displayName: 'Start: ${firstStop.displayName}', + ); + }, + icon: const Icon(Icons.directions, size: 20), + label: const Text( + 'NAVIGATE TO ROUTE START', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } } diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index 3439414..a1608be 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; +import '../../core/responsive/responsive_layout.dart'; +import '../../core/responsive/viewport_size.dart'; +import '../../core/theme/theme_extensions.dart'; import '../../services/auth/auth_providers.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; @@ -93,7 +96,7 @@ class _RoutePlannerScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not get GPS location: $e'), - backgroundColor: Colors.redAccent, + backgroundColor: context.motorColors.raceRed, ), ); } @@ -105,14 +108,15 @@ class _RoutePlannerScreenState extends ConsumerState { double? initialLng, String? defaultName, }) { + final colors = context.motorColors; final state = ref.read(routePlannerNotifierProvider); const maxAllowed = RouteService.maxTechnicalStops; if (existingStop == null && state.stops.length >= maxAllowed) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Maximum of $maxAllowed stops reached.'), - backgroundColor: Color(0xFF1B1E26), + SnackBar( + content: const Text('Maximum of 25 stops reached.'), + backgroundColor: colors.surfaceContainerHighest, ), ); return; @@ -141,10 +145,10 @@ class _RoutePlannerScreenState extends ConsumerState { builder: (dialogCtx) => StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( - backgroundColor: const Color(0xFF13151A), + backgroundColor: colors.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFF2A2E3D)), + side: BorderSide(color: colors.outlineVariant), ), title: Text( existingStop != null ? 'Edit Stop' : 'Add Route Stop', @@ -161,16 +165,16 @@ class _RoutePlannerScreenState extends ConsumerState { TextField( controller: nameCtrl, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Stop Name *', - labelStyle: TextStyle(color: Colors.white60), + labelStyle: const TextStyle(color: Colors.white60), hintText: 'e.g. Skyline Summit, Fuel Stop', - hintStyle: TextStyle(color: Colors.white24), + hintStyle: const TextStyle(color: Colors.white24), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF2A2E3D)), + borderSide: BorderSide(color: colors.outlineVariant), ), focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF00E5FF)), + borderSide: BorderSide(color: colors.cyberCyan), ), ), ), @@ -178,13 +182,13 @@ class _RoutePlannerScreenState extends ConsumerState { DropdownButtonFormField( initialValue: selectedType, isExpanded: true, - dropdownColor: const Color(0xFF1B1E26), + dropdownColor: colors.surfaceContainerHigh, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Stop Type', - labelStyle: TextStyle(color: Colors.white60), + labelStyle: const TextStyle(color: Colors.white60), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Color(0xFF2A2E3D)), + borderSide: BorderSide(color: colors.outlineVariant), ), ), items: RouteStopType.values.map((type) { @@ -194,7 +198,7 @@ class _RoutePlannerScreenState extends ConsumerState { children: [ Icon( _getIconForStopType(type), - color: const Color(0xFF00E5FF), + color: colors.cyberCyan, size: 18, ), const SizedBox(width: 8), @@ -283,7 +287,7 @@ class _RoutePlannerScreenState extends ConsumerState { ), ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF), + backgroundColor: colors.cyberCyan, foregroundColor: Colors.black, ), onPressed: () { @@ -405,7 +409,7 @@ class _RoutePlannerScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Route "${savedRoute.title}" saved successfully!'), - backgroundColor: const Color(0xFF00E676), + backgroundColor: context.motorColors.launchGreen, ), ); context.pop(savedRoute); @@ -448,11 +452,13 @@ class _RoutePlannerScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final colors = context.motorColors; + if (!_initialized) { - return const Scaffold( - backgroundColor: Color(0xFF0D0E11), + return Scaffold( + backgroundColor: colors.surfaceContainer, body: Center( - child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + child: CircularProgressIndicator(color: colors.cyberCyan), ), ); } @@ -480,10 +486,10 @@ class _RoutePlannerScreenState extends ConsumerState { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isFirst - ? const Color(0xFF00E676) + ? colors.launchGreen : isLast - ? const Color(0xFFFF5252) - : const Color(0xFF00E5FF), + ? colors.raceRed + : colors.cyberCyan, shape: BoxShape.circle, boxShadow: const [ BoxShadow(color: Colors.black54, blurRadius: 4, spreadRadius: 1), @@ -517,16 +523,16 @@ class _RoutePlannerScreenState extends ConsumerState { points: state.previewPoints .map((p) => AppLatLng(p.latitude, p.longitude)) .toList(), - color: const Color(0xFF00E5FF), + color: colors.cyberCyan, width: 3.5, ), ] : []; return Scaffold( - backgroundColor: const Color(0xFF0D0E11), + backgroundColor: colors.surfaceContainer, appBar: AppBar( - backgroundColor: const Color(0xFF13151A), + backgroundColor: colors.surfaceContainerHigh, elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), @@ -542,15 +548,15 @@ class _RoutePlannerScreenState extends ConsumerState { ), actions: [ if (_isSaving) - const Center( + Center( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, - color: Color(0xFF00E5FF), + color: colors.cyberCyan, ), ), ), @@ -559,583 +565,737 @@ class _RoutePlannerScreenState extends ConsumerState { TextButton.icon( key: const ValueKey('route_planner_save_button'), onPressed: _saveRoute, - icon: const Icon(Icons.save, color: Color(0xFF00E5FF), size: 18), - label: const Text( + icon: Icon(Icons.save, color: colors.cyberCyan, size: 18), + label: Text( 'SAVE', style: TextStyle( - color: Color(0xFF00E5FF), + color: colors.cyberCyan, fontWeight: FontWeight.bold, ), ), ), ], ), - body: LayoutBuilder( - builder: (context, constraints) { - final isNarrow = constraints.maxWidth < 360; - final isCompactHeight = constraints.maxHeight < 500; - final isVeryCompactHeight = constraints.maxHeight < 360; + body: ResponsiveLayout( + builder: (context, snapshot) { + final isNarrow = snapshot.width < 360; + final isExpandedLayout = + snapshot.sizeClass == WindowSizeClass.expanded || + (snapshot.isLandscape && snapshot.width >= 720); - // Dynamically compute map preview height based on available vertical space: - // Generous space (>= 620dp): 220dp - // Moderate space (500–619dp): 160dp - // Constrained space (380–499dp, e.g. IME keyboard open): 110dp - // Severely constrained space (280–379dp, e.g. landscape): 70dp - // Extreme constraint (< 280dp): 0dp (collapses so inputs stay visible and editable) - final double mapHeight; - final bool showDisclaimer; - if (constraints.maxHeight >= 620) { - mapHeight = 220.0; - showDisclaimer = true; - } else if (constraints.maxHeight >= 500) { - mapHeight = 160.0; - showDisclaimer = true; - } else if (constraints.maxHeight >= 380) { - mapHeight = 110.0; - showDisclaimer = false; - } else if (constraints.maxHeight >= 280) { - mapHeight = 70.0; - showDisclaimer = false; - } else { - mapHeight = 0.0; - showDisclaimer = false; + if (isExpandedLayout) { + return _buildExpandedLayout( + context, + colors, + state, + maxAllowed, + isAtLimit, + mapCenter, + markers, + polylines, + isNarrow, + ); } - return Column( - children: [ - // Error banner if any - if (_errorMessage != null) - Container( - width: double.infinity, - color: const Color(0xFFFF5252).withValues(alpha: 0.15), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - children: [ - const Icon( - Icons.error_outline, - color: Color(0xFFFF5252), - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: const TextStyle( - color: Color(0xFFFF5252), - fontSize: 12, - ), - ), - ), - IconButton( - icon: const Icon( - Icons.close, - color: Colors.white54, - size: 16, - ), - onPressed: () => setState(() => _errorMessage = null), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ), - - // Map Preview Section - if (mapHeight > 0) - SizedBox( - height: mapHeight, - width: double.infinity, - child: Stack( - children: [ - UnifiedMapView( - center: mapCenter, - zoom: 12.0, - markers: markers, - polylines: polylines, - ), - // Distance & Stops HUD overlay - Positioned( - top: isCompactHeight ? 4 : 8, - left: 8, - right: 8, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: isCompactHeight ? 4 : 6, - ), - decoration: BoxDecoration( - color: - const Color(0xFF13151A).withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFF2A2E3D)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - state.stops.length >= 2 - ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' - : 'Add 2+ stops for distance', - style: TextStyle( - color: const Color(0xFF00E5FF), - fontSize: isCompactHeight ? 11 : 12, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: isAtLimit - ? const Color( - 0xFFFFB300, - ).withValues(alpha: 0.2) - : const Color( - 0xFF00E5FF, - ).withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${state.stops.length}/$maxAllowed STOPS', - style: TextStyle( - color: isAtLimit - ? const Color(0xFFFFB300) - : const Color(0xFF00E5FF), - fontSize: 10, - fontWeight: FontWeight.w900, - ), - ), - ), - ], - ), - ), - ), - // Straight-line disclaimer note - if (showDisclaimer) - Positioned( - bottom: 4, - left: 8, - right: 8, - child: Text( - 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.65), - fontSize: 9, - shadows: const [ - Shadow(color: Colors.black, blurRadius: 4), - ], - ), - ), - ), - ], - ), - ), - - // Route Info Inputs (collapsible / compact) - Padding( - padding: EdgeInsets.fromLTRB( - 16, - isCompactHeight ? 4 : 12, - 16, - isCompactHeight ? 2 : 4, - ), - child: Column( - children: [ - TextField( - controller: _titleController, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Route Title (e.g. Skyline Ridge Loop) *', - hintStyle: const TextStyle( - color: Colors.white30, - fontSize: 14, - ), - isDense: true, - contentPadding: EdgeInsets.symmetric( - vertical: isCompactHeight ? 4 : 8, - ), - border: InputBorder.none, - ), - ), - Wrap( - spacing: 8, - runSpacing: 4, - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - PopupMenuButton( - initialValue: _visibility, - onSelected: (v) => setState(() => _visibility = v), - color: const Color(0xFF1B1E26), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: isCompactHeight ? 2 : 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.visibility_outlined, - size: 14, - color: Colors.white54, - ), - const SizedBox(width: 4), - Text( - _visibility.label, - style: const TextStyle( - color: Color(0xFF00E5FF), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const Icon( - Icons.arrow_drop_down, - size: 16, - color: Color(0xFF00E5FF), - ), - ], - ), - ), - itemBuilder: (context) => - RouteVisibility.values.map((v) { - return PopupMenuItem( - value: v, - child: Text( - v.label, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - ), - ), - ); - }).toList(), - ), - ], - ), - ], - ), - ), - - const Divider(color: Color(0xFF2A2E3D), height: 1), - - // Stop List Controls (Add Stop / Add GPS / Reverse) - Padding( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: isCompactHeight ? 4 : 8, - ), - child: Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - ElevatedButton.icon( - key: const ValueKey('route_planner_add_stop_button'), - onPressed: () => _showAddOrEditStopDialog(), - icon: const Icon(Icons.add_location_alt, size: 16), - label: Text(isNarrow ? 'ADD' : 'ADD STOP'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF), - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - key: const ValueKey( - 'route_planner_current_gps_button', - ), - onPressed: _addCurrentLocationStop, - icon: const Icon( - Icons.my_location, - size: 16, - color: Color(0xFF00E5FF), - ), - label: Text( - isNarrow ? 'GPS' : 'CURRENT GPS', - style: const TextStyle(color: Color(0xFF00E5FF)), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFF2A2E3D)), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - textStyle: const TextStyle(fontSize: 12), - ), - ), - ], - ), - if (state.stops.length >= 2) - TextButton.icon( - key: const ValueKey('route_planner_reverse_button'), - onPressed: () => ref - .read(routePlannerNotifierProvider.notifier) - .reverseStops(), - icon: const Icon( - Icons.swap_vert, - size: 16, - color: Colors.white70, - ), - label: const Text( - 'REVERSE', - style: TextStyle(color: Colors.white70, fontSize: 11), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ), - ), - - // Reorderable Stop List - Expanded( - child: state.stops.isEmpty - ? Center( - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isVeryCompactHeight) ...[ - Icon( - Icons.alt_route, - size: isCompactHeight ? 32 : 48, - color: Colors.white.withValues(alpha: 0.15), - ), - SizedBox(height: isCompactHeight ? 6 : 12), - ], - const Text( - 'No stops added yet.', - style: TextStyle( - color: Colors.white54, - fontSize: 14, - ), - ), - const SizedBox(height: 4), - const Text( - 'Tap "Add Stop" or "Current GPS" to begin planning.', - style: TextStyle( - color: Colors.white30, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ) - : ReorderableListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - itemCount: state.stops.length, - // ignore: deprecated_member_use - onReorder: (oldIdx, newIdx) { - ref - .read(routePlannerNotifierProvider.notifier) - .reorderStops(oldIdx, newIdx); - }, - itemBuilder: (context, index) { - final stop = state.stops[index]; - final isFirst = index == 0; - final isLast = - index == state.stops.length - 1 && - state.stops.length > 1; - - return Container( - key: ValueKey(stop.id), - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF13151A), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: const Color(0xFF2A2E3D), - ), - ), - child: Row( - children: [ - // Order badge - Container( - width: 26, - height: 26, - decoration: BoxDecoration( - color: isFirst - ? const Color(0xFF00E676) - : isLast - ? const Color(0xFFFF5252) - : const Color(0xFF00E5FF), - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - '${index + 1}', - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - const SizedBox(width: 10), - - // Stop Details - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _getIconForStopType(stop.stopType), - size: 14, - color: const Color(0xFF00E5FF), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - stop.displayName, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', - style: const TextStyle( - color: Colors.white38, - fontSize: 11, - ), - ), - if (stop.notes.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - stop.notes, - style: const TextStyle( - color: Colors.white70, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - - // Actions: Handoff navigation, Edit, Delete - IconButton( - icon: const Icon( - Icons.navigation, - color: Color(0xFF00E5FF), - size: 18, - ), - tooltip: 'Open in Maps', - onPressed: () { - NavigationHandoffService.showHandoffSheet( - context: context, - latitude: stop.latitude, - longitude: stop.longitude, - displayName: stop.displayName, - ); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - ), - ), - IconButton( - icon: const Icon( - Icons.edit, - color: Colors.white60, - size: 18, - ), - tooltip: 'Edit stop', - onPressed: () => _showAddOrEditStopDialog( - existingStop: stop, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - ), - ), - IconButton( - icon: const Icon( - Icons.delete_outline, - color: Color(0xFFFF5252), - size: 18, - ), - tooltip: 'Remove stop', - onPressed: () { - ref - .read( - routePlannerNotifierProvider.notifier, - ) - .removeStop(stop.id); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - ), - ), - const SizedBox(width: 4), - const Icon( - Icons.drag_handle, - color: Colors.white30, - size: 20, - ), - ], - ), - ); - }, - ), - ), - ], + return _buildCompactLayout( + context, + colors, + snapshot, + state, + maxAllowed, + isAtLimit, + mapCenter, + markers, + polylines, + isNarrow, ); }, ), ); } + + Widget _buildExpandedLayout( + BuildContext context, + MotorSignalThemeColors colors, + RoutePlannerState state, + int maxAllowed, + bool isAtLimit, + AppLatLng mapCenter, + List markers, + List polylines, + bool isNarrow, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Left Column: Map preview + HUD + Expanded( + flex: 5, + child: Container( + decoration: BoxDecoration( + border: Border( + right: BorderSide(color: colors.outlineVariant), + ), + ), + child: Stack( + children: [ + UnifiedMapView( + key: const ValueKey('route_planner_map_expanded'), + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + Positioned( + top: 12, + left: 12, + right: 12, + child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false), + ), + Positioned( + bottom: 8, + left: 12, + right: 12, + child: _buildDisclaimerText(), + ), + ], + ), + ), + ), + + // Right Column: Form inputs + Stop list + Expanded( + flex: 6, + child: Column( + children: [ + if (_errorMessage != null) _buildErrorBanner(colors), + Padding( + padding: const EdgeInsets.all(16), + child: _buildRouteInfoInputs(colors, false), + ), + Divider(color: colors.outlineVariant, height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: _buildStopControls(colors, state, isNarrow, false), + ), + Expanded( + child: _buildStopsList(colors, state, false, false), + ), + ], + ), + ), + ], + ); + } + + Widget _buildCompactLayout( + BuildContext context, + MotorSignalThemeColors colors, + ResponsiveSnapshot snapshot, + RoutePlannerState state, + int maxAllowed, + bool isAtLimit, + AppLatLng mapCenter, + List markers, + List polylines, + bool isNarrow, + ) { + final isCompactHeight = snapshot.height < 500; + final isVeryCompactHeight = snapshot.height < 360; + + // Dynamically compute map preview height based on available vertical space: + // Generous space (>= 620dp): 220dp + // Moderate space (500–619dp): 160dp + // Constrained space (380–499dp, e.g. IME keyboard open): 110dp + // Severely constrained space (280–379dp, e.g. landscape): 70dp + // Extreme constraint (< 280dp): 0dp (collapses so inputs stay visible and editable) + final double mapHeight; + final bool showDisclaimer; + if (snapshot.height >= 620) { + mapHeight = 220.0; + showDisclaimer = true; + } else if (snapshot.height >= 500) { + mapHeight = 160.0; + showDisclaimer = true; + } else if (snapshot.height >= 380) { + mapHeight = 110.0; + showDisclaimer = false; + } else if (snapshot.height >= 280) { + mapHeight = 70.0; + showDisclaimer = false; + } else { + mapHeight = 0.0; + showDisclaimer = false; + } + + return Column( + children: [ + if (_errorMessage != null) _buildErrorBanner(colors), + + // Map Preview Section + if (mapHeight > 0) + SizedBox( + height: mapHeight, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + key: const ValueKey('route_planner_map_compact'), + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + Positioned( + top: isCompactHeight ? 4 : 8, + left: 8, + right: 8, + child: _buildHudOverlay( + colors, + state, + maxAllowed, + isAtLimit, + isCompactHeight, + ), + ), + if (showDisclaimer) + Positioned( + bottom: 4, + left: 8, + right: 8, + child: _buildDisclaimerText(), + ), + ], + ), + ), + + // Route Info Inputs + Padding( + padding: EdgeInsets.fromLTRB( + 16, + isCompactHeight ? 4 : 12, + 16, + isCompactHeight ? 2 : 4, + ), + child: _buildRouteInfoInputs(colors, isCompactHeight), + ), + + Divider(color: colors.outlineVariant, height: 1), + + // Stop List Controls + Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: isCompactHeight ? 4 : 8, + ), + child: _buildStopControls(colors, state, isNarrow, isCompactHeight), + ), + + // Reorderable Stop List + Expanded( + child: _buildStopsList( + colors, + state, + isCompactHeight, + isVeryCompactHeight, + ), + ), + ], + ); + } + + Widget _buildErrorBanner(MotorSignalThemeColors colors) { + return Container( + width: double.infinity, + color: colors.raceRed.withValues(alpha: 0.15), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colors.raceRed, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: colors.raceRed, + fontSize: 12, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.close, + color: Colors.white54, + size: 16, + ), + onPressed: () => setState(() => _errorMessage = null), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + Widget _buildHudOverlay( + MotorSignalThemeColors colors, + RoutePlannerState state, + int maxAllowed, + bool isAtLimit, + bool isCompactHeight, + ) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: isCompactHeight ? 4 : 6, + ), + decoration: BoxDecoration( + color: colors.surfaceContainer.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + state.stops.length >= 2 + ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' + : 'Add 2+ stops for distance', + style: TextStyle( + color: colors.cyberCyan, + fontSize: isCompactHeight ? 11 : 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isAtLimit + ? colors.warningAmber.withValues(alpha: 0.2) + : colors.cyberCyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${state.stops.length}/$maxAllowed STOPS', + style: TextStyle( + color: isAtLimit ? colors.warningAmber : colors.cyberCyan, + fontSize: 10, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDisclaimerText() { + return Text( + 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 9, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ); + } + + Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) { + return Column( + children: [ + TextField( + controller: _titleController, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Route Title (e.g. Skyline Ridge Loop) *', + hintStyle: const TextStyle( + color: Colors.white30, + fontSize: 14, + ), + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: isCompactHeight ? 4 : 8, + ), + border: InputBorder.none, + ), + ), + Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + PopupMenuButton( + initialValue: _visibility, + onSelected: (v) => setState(() => _visibility = v), + color: colors.surfaceContainerHigh, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: isCompactHeight ? 2 : 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.visibility_outlined, + size: 14, + color: Colors.white54, + ), + const SizedBox(width: 4), + Text( + _visibility.label, + style: TextStyle( + color: colors.cyberCyan, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + Icon( + Icons.arrow_drop_down, + size: 16, + color: colors.cyberCyan, + ), + ], + ), + ), + itemBuilder: (context) => RouteVisibility.values.map((v) { + return PopupMenuItem( + value: v, + child: Text( + v.label, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), + ), + ); + }).toList(), + ), + ], + ), + ], + ); + } + + Widget _buildStopControls( + MotorSignalThemeColors colors, + RoutePlannerState state, + bool isNarrow, + bool isCompactHeight, + ) { + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + key: const ValueKey('route_planner_add_stop_button'), + onPressed: () => _showAddOrEditStopDialog(), + icon: const Icon(Icons.add_location_alt, size: 16), + label: Text(isNarrow ? 'ADD' : 'ADD STOP'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + key: const ValueKey('route_planner_current_gps_button'), + onPressed: _addCurrentLocationStop, + icon: Icon( + Icons.my_location, + size: 16, + color: colors.cyberCyan, + ), + label: Text( + isNarrow ? 'GPS' : 'CURRENT GPS', + style: TextStyle(color: colors.cyberCyan), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: colors.outlineVariant), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ), + if (state.stops.length >= 2) + TextButton.icon( + key: const ValueKey('route_planner_reverse_button'), + onPressed: () => ref + .read(routePlannerNotifierProvider.notifier) + .reverseStops(), + icon: const Icon( + Icons.swap_vert, + size: 16, + color: Colors.white70, + ), + label: const Text( + 'REVERSE', + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ); + } + + Widget _buildStopsList( + MotorSignalThemeColors colors, + RoutePlannerState state, + bool isCompactHeight, + bool isVeryCompactHeight, + ) { + if (state.stops.isEmpty) { + return Center( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isVeryCompactHeight) ...[ + Icon( + Icons.alt_route, + size: isCompactHeight ? 32 : 48, + color: Colors.white.withValues(alpha: 0.15), + ), + SizedBox(height: isCompactHeight ? 6 : 12), + ], + const Text( + 'No stops added yet.', + style: TextStyle( + color: Colors.white54, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + const Text( + 'Tap "Add Stop" or "Current GPS" to begin planning.', + style: TextStyle( + color: Colors.white30, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + return ReorderableListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + itemCount: state.stops.length, + // ignore: deprecated_member_use + onReorder: (oldIdx, newIdx) { + ref + .read(routePlannerNotifierProvider.notifier) + .reorderStops(oldIdx, newIdx); + }, + itemBuilder: (context, index) { + final stop = state.stops[index]; + final isFirst = index == 0; + final isLast = + index == state.stops.length - 1 && state.stops.length > 1; + + return Container( + key: ValueKey(stop.id), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + // Order badge + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: isFirst + ? colors.launchGreen + : isLast + ? colors.raceRed + : colors.cyberCyan, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 10), + + // Stop Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getIconForStopType(stop.stopType), + size: 14, + color: colors.cyberCyan, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + stop.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + if (stop.notes.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + stop.notes, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // Actions: Handoff navigation, Edit, Delete + IconButton( + icon: Icon( + Icons.navigation, + color: colors.cyberCyan, + size: 18, + ), + tooltip: 'Open in Maps', + onPressed: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + ), + ), + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.white60, + size: 18, + ), + tooltip: 'Edit stop', + onPressed: () => _showAddOrEditStopDialog( + existingStop: stop, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + ), + ), + IconButton( + icon: Icon( + Icons.delete_outline, + color: colors.raceRed, + size: 18, + ), + tooltip: 'Remove stop', + onPressed: () { + ref + .read( + routePlannerNotifierProvider.notifier, + ) + .removeStop(stop.id); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.drag_handle, + color: Colors.white30, + size: 20, + ), + ], + ), + ); + }, + ); + } } diff --git a/lib/services/routes/navigation_handoff.dart b/lib/services/routes/navigation_handoff.dart index f1af5ff..379c0d0 100644 --- a/lib/services/routes/navigation_handoff.dart +++ b/lib/services/routes/navigation_handoff.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../core/theme/theme_extensions.dart'; import 'route_models.dart'; /// Target navigation application for handoff. @@ -67,9 +68,10 @@ class NavigationHandoffService { required double longitude, required String displayName, }) { + final colors = context.motorColors; showModalBottomSheet( context: context, - backgroundColor: const Color(0xFF1B1E26), + backgroundColor: colors.surfaceContainer, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), @@ -83,9 +85,9 @@ class NavigationHandoffService { children: [ Row( children: [ - const Icon( + Icon( Icons.navigation, - color: Color(0xFF00E5FF), + color: colors.cyberCyan, size: 22, ), const SizedBox(width: 12), @@ -93,13 +95,13 @@ class NavigationHandoffService { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'NAVIGATION HANDOFF', style: TextStyle( fontSize: 11, letterSpacing: 1.2, fontWeight: FontWeight.w700, - color: Color(0xFF00E5FF), + color: colors.cyberCyan, ), ), const SizedBox(height: 2), @@ -169,9 +171,9 @@ class NavigationHandoffService { color: Colors.white10, borderRadius: BorderRadius.circular(8), ), - child: const Icon( + child: Icon( Icons.directions_car, - color: Color(0xFF00E5FF), + color: colors.cyberCyan, ), ), title: const Text( diff --git a/test/canonical_routes_maps_responsive_test.dart b/test/canonical_routes_maps_responsive_test.dart new file mode 100644 index 0000000..ed48e98 --- /dev/null +++ b/test/canonical_routes_maps_responsive_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/features/map/map_screen.dart'; +import 'package:motorcycle_dash/features/routes/route_detail_screen.dart'; +import 'package:motorcycle_dash/features/routes/route_planner_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/map/unified_map_view.dart'; +import 'package:motorcycle_dash/services/routes/navigation_handoff.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; +import 'package:motorcycle_dash/services/routes/route_providers.dart'; + +const List testViewports = [ + Size(320, 640), + Size(360, 800), + Size(390, 844), + Size(412, 915), + Size(640, 320), + Size(800, 360), + Size(844, 390), + Size(768, 1024), + Size(1024, 768), +]; + +MotorSignalRoute createSampleRoute({ + String id = 'sample-route-1', + String title = 'Skyline Ridge Cruise', + String description = 'Scenic ridge route with twisties', + String ownerUid = 'alice', + RouteVisibility visibility = RouteVisibility.public, + String? attachedGroupRideId, +}) { + return MotorSignalRoute( + id: id, + title: title, + description: description, + ownerUid: ownerUid, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 2), + visibility: visibility, + attachedGroupRideId: attachedGroupRideId, + stops: const [ + RouteStop( + id: 'stop-1', + orderIndex: 0, + latitude: 37.3861, + longitude: -122.2641, + displayName: 'Alice\'s Restaurant', + stopType: RouteStopType.start, + notes: 'Meet at 9:00 AM', + ), + RouteStop( + id: 'stop-2', + orderIndex: 1, + latitude: 37.3200, + longitude: -122.2100, + displayName: 'Skyline Vista Point', + stopType: RouteStopType.scenic, + notes: 'Quick photo stop', + ), + RouteStop( + id: 'stop-3', + orderIndex: 2, + latitude: 37.2500, + longitude: -122.1800, + displayName: 'Summit Overlook', + stopType: RouteStopType.destination, + notes: 'Final regroup', + ), + ], + ); +} + +void main() { + group('UI-2E MapScreen Responsive Matrix', () { + for (final size in testViewports) { + testWidgets('MapScreen live mode renders cleanly at ${size.width}x${size.height}', (tester) async { + await tester.binding.setSurfaceSize(size); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const MapScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Live Map'), findsOneWidget); + expect(find.byType(UnifiedMapView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + }); + + group('UI-2E RoutePlannerScreen Responsive Matrix', () { + for (final size in testViewports) { + testWidgets('RoutePlannerScreen renders without overflow at ${size.width}x${size.height}', (tester) async { + await tester.binding.setSurfaceSize(size); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('ROUTE PLANNER'), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_save_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_add_stop_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_current_gps_button')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('RoutePlannerScreen handles adding stops and reversing', (tester) async { + await tester.binding.setSurfaceSize(const Size(412, 915)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Add stop 1 + await tester.tap(find.byKey(const ValueKey('route_planner_add_stop_button'))); + await tester.pumpAndSettle(); + var fields = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)); + await tester.enterText(fields.at(0), 'Start Point'); + await tester.enterText(fields.at(1), '37.3861'); + await tester.enterText(fields.at(2), '-122.2641'); + await tester.tap(find.text('ADD')); + await tester.pumpAndSettle(); + + // Add stop 2 + await tester.tap(find.byKey(const ValueKey('route_planner_add_stop_button'))); + await tester.pumpAndSettle(); + fields = find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)); + await tester.enterText(fields.at(0), 'End Point'); + await tester.enterText(fields.at(1), '37.2500'); + await tester.enterText(fields.at(2), '-122.1800'); + await tester.tap(find.text('ADD')); + await tester.pumpAndSettle(); + + expect(find.text('Start Point'), findsOneWidget); + expect(find.text('End Point'), findsOneWidget); + expect(find.text('2/25 STOPS'), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_reverse_button')), findsOneWidget); + + // Reverse stops + await tester.tap(find.byKey(const ValueKey('route_planner_reverse_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Start Point'), findsOneWidget); + expect(find.text('End Point'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('RoutePlannerScreen wide layout renders split-pane', (tester) async { + await tester.binding.setSurfaceSize(const Size(1024, 768)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('route_planner_map_expanded')), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_save_button')), findsOneWidget); + expect(find.text('ROUTE PLANNER'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('UI-2E RouteDetailScreen Responsive Matrix', () { + for (final size in testViewports) { + testWidgets('RouteDetailScreen renders without overflow at ${size.width}x${size.height}', (tester) async { + await tester.binding.setSurfaceSize(size); + final sampleRoute = createSampleRoute(attachedGroupRideId: 'group-ride-123'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser(uid: 'alice', isAnonymous: false)), + routeByIdProvider('sample-route-1').overrideWith((ref) => Stream.value(sampleRoute)), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const RouteDetailScreen(routeId: 'sample-route-1'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('ROUTE DETAILS'), findsOneWidget); + expect(find.text('Skyline Ridge Cruise'), findsOneWidget); + expect(find.text('Scenic ridge route with twisties'), findsOneWidget); + expect(find.text('STRAIGHT-LINE DISTANCE'), findsOneWidget); + expect(find.text('WAYPOINTS'), findsOneWidget); + expect(find.text('3 STOPS'), findsOneWidget); + expect(find.byKey(const ValueKey('route_detail_edit_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_detail_delete_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_detail_view_attached_group_ride_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_detail_navigate_start_button')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + }); + + group('UI-2E NavigationHandoffService Modal', () { + testWidgets('Navigation handoff bottom sheet displays options with theme tokens', (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: 37.3861, + longitude: -122.2641, + displayName: 'Alice\'s Restaurant', + ); + }, + child: const Text('OPEN HANDOFF'), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Open sheet + await tester.tap(find.text('OPEN HANDOFF')); + await tester.pumpAndSettle(); + + expect(find.text('NAVIGATION HANDOFF'), findsOneWidget); + expect(find.text('Alice\'s Restaurant'), findsOneWidget); + expect(find.byKey(const ValueKey('handoff_google_maps_tile')), findsOneWidget); + expect(find.byKey(const ValueKey('handoff_waze_tile')), findsOneWidget); + expect(find.byKey(const ValueKey('handoff_system_map_tile')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +}