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

1405 lines
45 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 'widgets/address_autocomplete_field.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;
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) {
return AlertDialog(
backgroundColor: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
title: Text(
existingStop != null ? 'Edit Stop' : 'Add Route Stop',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
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,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Stop Name *',
labelStyle: const TextStyle(color: Colors.white60),
hintText: 'e.g. Skyline Summit, Fuel Stop',
hintStyle: const TextStyle(color: Colors.white24),
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: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Stop Type',
labelStyle: const TextStyle(color: Colors.white60),
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,
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: const TextStyle(
color: Colors.white,
fontSize: 13,
),
decoration: const InputDecoration(
labelText: 'Latitude *',
labelStyle: TextStyle(color: Colors.white60),
hintText: '37.421998',
hintStyle: TextStyle(color: Colors.white24),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: lngCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
decoration: const InputDecoration(
labelText: 'Longitude *',
labelStyle: TextStyle(color: Colors.white60),
hintText: '-122.084000',
hintStyle: TextStyle(color: Colors.white24),
),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: notesCtrl,
style: const TextStyle(color: Colors.white),
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Notes (Optional)',
labelStyle: TextStyle(color: Colors.white60),
hintText: 'Regroup point, scenic view, cautions...',
hintStyle: TextStyle(color: Colors.white24),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text(
'CANCEL',
style: TextStyle(color: Colors.white54),
),
),
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 (lat == null || lat < -90.0 || lat > 90.0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid latitude (-90 to 90).'),
),
);
return;
}
if (lng == null || lng < -180.0 || lng > 180.0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid longitude (-180 to 180).'),
),
);
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: [
const Text(
'SEARCH ADDRESS OR PLACE',
style: TextStyle(
color: Colors.white,
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 first stop or default
final mapCenter = 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
final polylines = state.stops.length >= 2
? [
AppPolyline(
id: 'straight_line_preview',
points: state.previewPoints
.map((p) => AppLatLng(p.latitude, p.longitude))
.toList(),
color: colors.cyberCyan,
width: 3.5,
),
]
: <AppPolyline>[];
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => context.pop(),
),
title: Text(
widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER',
style: const TextStyle(
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 >= 720);
if (isExpandedLayout) {
return _buildExpandedLayout(
context,
colors,
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,
RoutePlannerState state,
int maxAllowed,
bool isAtLimit,
AppLatLng mapCenter,
List<AppMapMarker> markers,
List<AppPolyline> 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<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;
}
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<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: [
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,
),
],
),
);
},
);
}
}