MotorSignal-App/lib/features/routes/route_planner_screen.dart

1626 lines
54 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'package:flutter/material.dart';
import 'package: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';
import '../../services/routes/navigation_handoff.dart';
import '../../services/routes/route_models.dart';
import '../../services/routes/route_providers.dart';
import '../../services/routes/route_service.dart';
import 'models/route_curvature_analysis.dart';
import 'widgets/address_autocomplete_field.dart';
import 'widgets/curviness_priority_selector.dart';
class RoutePlannerScreen extends ConsumerStatefulWidget {
final String? initialRouteId;
final String? attachToGroupRideId;
const RoutePlannerScreen({
super.key,
this.initialRouteId,
this.attachToGroupRideId,
});
@override
ConsumerState<RoutePlannerScreen> createState() => _RoutePlannerScreenState();
}
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
RouteVisibility _visibility = RouteVisibility.private;
CurvinessPriority _curvinessPriority = CurvinessPriority.standard;
bool _initialized = false;
bool _isSaving = false;
String? _errorMessage;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadInitialData();
});
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
super.dispose();
}
Future<void> _loadInitialData() async {
if (widget.initialRouteId != null) {
final service = ref.read(routeServiceProvider);
final route = await service.getRoute(widget.initialRouteId!);
if (route != null && mounted) {
ref.read(routePlannerNotifierProvider.notifier).loadRoute(route);
_titleController.text = route.title;
_descController.text = route.description;
setState(() {
_visibility = route.visibility;
_initialized = true;
});
return;
}
} else {
ref.read(routePlannerNotifierProvider.notifier).reset();
if (widget.attachToGroupRideId != null) {
setState(() {
_visibility = RouteVisibility.groupOnly;
});
}
}
setState(() {
_initialized = true;
});
}
Future<void> _addCurrentLocationStop() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 10),
),
);
if (!mounted) return;
_showAddOrEditStopDialog(
initialLat: position.latitude,
initialLng: position.longitude,
defaultName: 'My Current Location',
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not get GPS location: $e'),
backgroundColor: context.motorColors.raceRed,
),
);
}
}
void _showAddOrEditStopDialog({
RouteStop? existingStop,
double? initialLat,
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(
SnackBar(
content: const Text('Maximum of 25 stops reached.'),
backgroundColor: colors.surfaceContainerHighest,
),
);
return;
}
final nameCtrl = TextEditingController(
text: existingStop?.displayName ?? defaultName ?? '',
);
final latCtrl = TextEditingController(
text: existingStop != null
? existingStop.latitude.toStringAsFixed(6)
: (initialLat?.toStringAsFixed(6) ?? ''),
);
final lngCtrl = TextEditingController(
text: existingStop != null
? existingStop.longitude.toStringAsFixed(6)
: (initialLng?.toStringAsFixed(6) ?? ''),
);
final notesCtrl = TextEditingController(text: existingStop?.notes ?? '');
RouteStopType selectedType =
existingStop?.stopType ??
(state.stops.isEmpty ? RouteStopType.origin : RouteStopType.waypoint);
showDialog(
context: context,
builder: (dialogCtx) => StatefulBuilder(
builder: (context, setDialogState) {
final screenSize = MediaQuery.sizeOf(context);
final isCompactDialog = screenSize.height < 480;
return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainer,
insetPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactDialog ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 12 : 20,
20,
0,
),
contentPadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 10 : 16,
20,
isCompactDialog ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactDialog ? 8 : 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
title: Text(
existingStop != null ? 'Edit Stop' : 'Add Route Stop',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.cyberCyan),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
minimumSize: const Size(double.infinity, 36),
),
icon:
Icon(Icons.search, size: 16, color: colors.cyberCyan),
label: Text(
'SEARCH ADDRESS / POI',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
onPressed: () => _openAddressSearchForStop(
context,
colors,
setDialogState,
nameCtrl,
latCtrl,
lngCtrl,
notesCtrl,
),
),
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Stop Name *',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: 'e.g. Skyline Summit, Fuel Stop',
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
suffixIcon: IconButton(
icon: Icon(Icons.search,
color: colors.cyberCyan, size: 20),
tooltip: 'Search address',
onPressed: () => _openAddressSearchForStop(
context,
colors,
setDialogState,
nameCtrl,
latCtrl,
lngCtrl,
notesCtrl,
),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.cyberCyan),
),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<RouteStopType>(
initialValue: selectedType,
isExpanded: true,
dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Stop Type',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: colors.outlineVariant),
),
),
items: RouteStopType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Row(
children: [
Icon(
_getIconForStopType(type),
color: colors.cyberCyan,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
type.label,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: (val) {
if (val != null) {
setDialogState(() => selectedType = val);
}
},
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: latCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 13,
),
decoration: InputDecoration(
labelText: 'Latitude *',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: '37.421998',
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: lngCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 13,
),
decoration: InputDecoration(
labelText: 'Longitude *',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: '-122.084000',
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: notesCtrl,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 2,
decoration: InputDecoration(
labelText: 'Notes (Optional)',
labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
hintText: 'Regroup point, scenic view, cautions...',
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: Text(
'CANCEL',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
onPressed: () {
final name = nameCtrl.text.trim();
final lat = double.tryParse(latCtrl.text.trim());
final lng = double.tryParse(lngCtrl.text.trim());
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please provide a stop name.'),
),
);
return;
}
if (!RoutePlannerNotifier.isValidCoordinate(lat, lng)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid coordinates: latitude [-90, 90], longitude [-180, 180], non-zero.'),
),
);
return;
}
final notifier = ref.read(
routePlannerNotifierProvider.notifier,
);
if (existingStop != null) {
notifier.updateStop(
existingStop.copyWith(
displayName: name,
latitude: lat!,
longitude: lng!,
stopType: selectedType,
notes: notesCtrl.text.trim(),
),
);
} else {
notifier.addStop(
displayName: name,
latitude: lat!,
longitude: lng!,
stopType: selectedType,
notes: notesCtrl.text.trim(),
);
}
Navigator.pop(dialogCtx);
},
child: Text(existingStop != null ? 'SAVE' : 'ADD'),
),
],
);
},
),
);
}
void _openAddressSearchForStop(
BuildContext context,
MotorSignalThemeColors colors,
void Function(void Function()) setDialogState,
TextEditingController nameCtrl,
TextEditingController latCtrl,
TextEditingController lngCtrl,
TextEditingController notesCtrl,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetCtx) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetCtx).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'SEARCH ADDRESS OR PLACE',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 12),
AddressAutocompleteField(
labelText: 'Search location',
autofocus: true,
onPlaceResolved: (place) {
if (place != null) {
setDialogState(() {
nameCtrl.text = place.displayName;
latCtrl.text = place.latitude.toStringAsFixed(6);
lngCtrl.text = place.longitude.toStringAsFixed(6);
if (notesCtrl.text.isEmpty &&
place.formattedAddress != null) {
notesCtrl.text = place.formattedAddress!;
}
});
Navigator.pop(sheetCtx);
}
},
),
],
),
),
);
}
Future<void> _saveRoute() async {
final title = _titleController.text.trim();
if (title.length < 3 || title.length > 80) {
setState(() {
_errorMessage = 'Route title must be between 3 and 80 characters.';
});
return;
}
final user = ref.read(currentUserProvider);
if (user == null) {
setState(() {
_errorMessage = 'You must be signed in to save a route.';
});
return;
}
final state = ref.read(routePlannerNotifierProvider);
const maxAllowed = RouteService.maxTechnicalStops;
if (state.stops.length > maxAllowed) {
setState(() {
_errorMessage = 'Cannot exceed $maxAllowed stops.';
});
return;
}
setState(() {
_isSaving = true;
_errorMessage = null;
});
try {
final notifier = ref.read(routePlannerNotifierProvider.notifier);
final savedRoute = await notifier.saveRoute(
title: title,
description: _descController.text.trim(),
ownerUid: user.uid,
visibility: _visibility,
attachedGroupRideId: widget.attachToGroupRideId,
);
// If attached to a group ride, update the group ride with route reference
if (widget.attachToGroupRideId != null) {
final service = ref.read(routeServiceProvider);
await service.attachRouteToGroupRide(
groupRideId: widget.attachToGroupRideId!,
route: savedRoute,
callerUid: user.uid,
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Route "${savedRoute.title}" saved successfully!'),
backgroundColor: context.motorColors.launchGreen,
),
);
context.pop(savedRoute);
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to save route: $e';
_isSaving = false;
});
}
}
}
IconData _getIconForStopType(RouteStopType type) {
switch (type) {
case RouteStopType.start:
case RouteStopType.origin:
return Icons.play_circle_fill;
case RouteStopType.meetup:
return Icons.people;
case RouteStopType.waypoint:
return Icons.place;
case RouteStopType.fuel:
return Icons.local_gas_station;
case RouteStopType.rest:
return Icons.hotel;
case RouteStopType.food:
return Icons.restaurant;
case RouteStopType.scenic:
return Icons.photo_camera;
case RouteStopType.regroup:
return Icons.group;
case RouteStopType.destination:
return Icons.flag;
case RouteStopType.other:
return Icons.location_on;
}
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
if (!_initialized) {
return Scaffold(
backgroundColor: colors.surfaceContainer,
body: Center(
child: CircularProgressIndicator(color: colors.cyberCyan),
),
);
}
final state = ref.watch(routePlannerNotifierProvider);
const maxAllowed = RouteService.maxTechnicalStops;
final isAtLimit = state.stops.length >= maxAllowed;
// Center map on road geometry, first stop, or default
final mapCenter = state.geometry.isNotEmpty
? state.geometry.first
: (state.stops.isNotEmpty
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
: const AppLatLng(37.7749, -122.4194));
// Build markers
final markers = state.stops.map((stop) {
final isFirst = stop.orderIndex == 0;
final isLast =
stop.orderIndex == state.stops.length - 1 && state.stops.length > 1;
return AppMapMarker(
id: stop.id,
position: AppLatLng(stop.latitude, stop.longitude),
label: stop.displayName,
iconWidget: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isFirst
? colors.launchGreen
: isLast
? colors.raceRed
: colors.cyberCyan,
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 4, spreadRadius: 1),
],
),
child: Text(
'${stop.orderIndex + 1}',
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
onTap: () {
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: stop.latitude,
longitude: stop.longitude,
displayName: stop.displayName,
);
},
);
}).toList();
// Build polylines from authoritative calculated road geometry
final polylines = <AppPolyline>[];
if (state.geometry.isNotEmpty) {
polylines.add(
AppPolyline(
id: 'route_planner_road_geometry',
points: state.geometry,
color: colors.cyberCyan,
width: 3.5,
),
);
}
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(),
),
title: Text(
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontSize: 16,
),
),
actions: [
if (_isSaving)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.cyberCyan,
),
),
),
)
else
TextButton.icon(
key: const ValueKey('route_planner_save_button'),
onPressed: _saveRoute,
icon: Icon(Icons.save, color: colors.cyberCyan, size: 18),
label: Text(
'SAVE',
style: TextStyle(
color: colors.cyberCyan,
fontWeight: FontWeight.bold,
),
),
),
],
),
body: ResponsiveLayout(
builder: (context, snapshot) {
final isNarrow = snapshot.width < 360;
final isExpandedLayout =
snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 600);
if (isExpandedLayout) {
return _buildExpandedLayout(
context,
colors,
snapshot,
state,
maxAllowed,
isAtLimit,
mapCenter,
markers,
polylines,
isNarrow,
);
}
return _buildCompactLayout(
context,
colors,
snapshot,
state,
maxAllowed,
isAtLimit,
mapCenter,
markers,
polylines,
isNarrow,
);
},
),
);
}
Widget _buildExpandedLayout(
BuildContext context,
MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot,
RoutePlannerState state,
int maxAllowed,
bool isAtLimit,
AppLatLng mapCenter,
List<AppMapMarker> markers,
List<AppPolyline> polylines,
bool isNarrow,
) {
final isCompactHeight = snapshot.height < 500;
final isVeryCompactHeight = snapshot.height < 360;
final showDisclaimer = snapshot.height >= 260;
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: isCompactHeight ? 6 : 12,
left: isCompactHeight ? 8 : 12,
right: isCompactHeight ? 8 : 12,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
isCompactHeight,
),
),
if (showDisclaimer)
Positioned(
bottom: 6,
left: 8,
right: 8,
child: _buildDisclaimerText(),
),
],
),
),
),
// Right Column: Form inputs + Stop list
Expanded(
flex: 6,
child: isCompactHeight
? _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
child: _buildRouteInfoInputs(colors, true),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child:
_buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
)
: 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<AppMapMarker> markers,
List<AppPolyline> 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 (340–379dp): 60dp
// Extreme constraint (< 340dp, e.g. 640x320 landscape): 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 >= 340) {
mapHeight = 60.0;
showDisclaimer = false;
} else {
mapHeight = 0.0;
showDisclaimer = false;
}
if (isCompactHeight) {
return _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
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: 4,
left: 8,
right: 8,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
true,
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: _buildRouteInfoInputs(colors, true),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: _buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
);
}
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: 8,
left: 8,
right: 8,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
false,
),
),
if (showDisclaimer)
Positioned(
bottom: 4,
left: 8,
right: 8,
child: _buildDisclaimerText(),
),
],
),
),
// Route Info Inputs
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _buildRouteInfoInputs(colors, false),
),
Divider(color: colors.outlineVariant, height: 1),
// Stop List Controls
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _buildStopControls(colors, state, isNarrow, false),
),
// Reorderable Stop List
Expanded(
child: _buildStopsList(
colors,
state,
false,
isVeryCompactHeight,
),
),
],
);
}
Widget _buildErrorBanner(MotorSignalThemeColors colors, [String? message]) {
final effectiveError = message ?? _errorMessage;
if (effectiveError == null) return const SizedBox.shrink();
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(
effectiveError,
style: TextStyle(
color: colors.raceRed,
fontSize: 12,
),
),
),
IconButton(
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 16,
),
onPressed: () => setState(() => _errorMessage = null),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
Widget _buildHudOverlay(
MotorSignalThemeColors colors,
RoutePlannerState state,
int maxAllowed,
bool isAtLimit,
bool isCompactHeight,
) {
String distanceText;
if (state.stops.length < 2) {
distanceText = 'Add 2+ stops for route';
} else if (state.isCalculatingRoute) {
distanceText = 'Calculating road route...';
} else if (state.routeCalculationError != null) {
distanceText = 'Route calculation failed';
} else if (state.geometry.isNotEmpty && state.estimatedDistanceMeters != null) {
distanceText =
'Road: ${state.formattedRoadDistanceMiles} (${state.formattedRoadDistanceKm})${state.formattedRoadDuration != null ? ' • ${state.formattedRoadDuration}' : ''}';
} else {
distanceText = 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})';
}
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(
distanceText,
style: TextStyle(
color: colors.cyberCyan,
fontSize: isCompactHeight ? 11 : 12,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
if (state.stops.length >= 2) ...[
Builder(builder: (context) {
final report = RouteCurvatureReport.analyze(
geometry: state.geometry,
stops: state.stops,
priority: _curvinessPriority,
);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: colors.launchGreen.withValues(alpha: 0.35)),
),
child: Text(
'${report.totalCorners} CORNERS • ${report.formattedElevationGain}',
style: TextStyle(
color: colors.launchGreen,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
);
}),
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() {
final theme = Theme.of(context);
return Text(
'Calculated road route. Navigation handoff launches in Google Maps / Waze.',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.75),
fontSize: 9,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 4),
],
),
);
}
Widget _buildRouteInfoInputs(MotorSignalThemeColors colors, bool isCompactHeight) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return Column(
children: [
TextField(
controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(
color: onSurface,
fontWeight: FontWeight.bold,
fontSize: 15,
),
decoration: InputDecoration(
hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
hintStyle: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.5),
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<RouteVisibility>(
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: [
Icon(
Icons.visibility_outlined,
size: 14,
color: onSurfaceVariant,
),
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: TextStyle(
color: onSurface,
fontSize: 13,
),
),
);
}).toList(),
),
],
),
],
);
}
Widget _buildStopControls(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isNarrow,
bool isCompactHeight,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CurvinessPrioritySelector(
selected: _curvinessPriority,
onChanged: (p) => setState(() => _curvinessPriority = p),
colors: colors,
isCompact: isCompactHeight,
),
SizedBox(height: isCompactHeight ? 6 : 8),
Wrap(
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
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: EdgeInsets.symmetric(
horizontal: 10,
vertical: isCompactHeight ? 6 : 8,
),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
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: EdgeInsets.symmetric(
horizontal: 10,
vertical: isCompactHeight ? 6 : 8,
),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
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: Icon(
Icons.swap_vert,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
label: Text(
'REVERSE',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
],
);
}
Widget _buildEmptyStopsContent(
MotorSignalThemeColors colors,
bool isCompactHeight,
bool isVeryCompactHeight,
) {
final theme = Theme.of(context);
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return Padding(
padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isVeryCompactHeight) ...[
Icon(
Icons.alt_route,
size: isCompactHeight ? 28 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2),
),
SizedBox(height: isCompactHeight ? 4 : 12),
],
Text(
'No stops added yet.',
style: TextStyle(
color: onSurfaceVariant,
fontSize: isCompactHeight ? 13 : 14,
),
),
const SizedBox(height: 4),
Text(
'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: isCompactHeight ? 11 : 12,
),
),
],
),
);
}
Widget _buildStopsList(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight,
bool isVeryCompactHeight, {
Widget? header,
}) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (header == null && state.stops.isEmpty) {
return Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: _buildEmptyStopsContent(
colors,
isCompactHeight,
isVeryCompactHeight,
),
),
);
}
return ReorderableListView.builder(
header: header,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
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: TextStyle(
color: onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
Text(
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
style: TextStyle(
color: onSurfaceVariant,
fontSize: 11,
),
),
if (stop.notes.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
stop.notes,
style: TextStyle(
color: onSurfaceVariant,
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: Icon(
Icons.edit,
color: onSurfaceVariant,
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),
Icon(
Icons.drag_handle,
color: onSurfaceVariant.withValues(alpha: 0.4),
size: 20,
),
],
),
);
},
);
}
}