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

1142 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 '../../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';
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: Colors.redAccent,
),
);
}
}
void _showAddOrEditStopDialog({
RouteStop? existingStop,
double? initialLat,
double? initialLng,
String? defaultName,
}) {
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),
),
);
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: const Color(0xFF13151A),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFF2A2E3D)),
),
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: [
TextField(
controller: nameCtrl,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Stop Name *',
labelStyle: TextStyle(color: Colors.white60),
hintText: 'e.g. Skyline Summit, Fuel Stop',
hintStyle: TextStyle(color: Colors.white24),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFF2A2E3D)),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFF00E5FF)),
),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<RouteStopType>(
initialValue: selectedType,
isExpanded: true,
dropdownColor: const Color(0xFF1B1E26),
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Stop Type',
labelStyle: TextStyle(color: Colors.white60),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFF2A2E3D)),
),
),
items: RouteStopType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Row(
children: [
Icon(
_getIconForStopType(type),
color: const Color(0xFF00E5FF),
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: const Color(0xFF00E5FF),
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'),
),
],
);
},
),
);
}
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: const Color(0xFF00E676),
),
);
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) {
if (!_initialized) {
return const Scaffold(
backgroundColor: Color(0xFF0D0E11),
body: Center(
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
),
);
}
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
? const Color(0xFF00E676)
: isLast
? const Color(0xFFFF5252)
: const Color(0xFF00E5FF),
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: const Color(0xFF00E5FF),
width: 3.5,
),
]
: <AppPolyline>[];
return Scaffold(
backgroundColor: const Color(0xFF0D0E11),
appBar: AppBar(
backgroundColor: const Color(0xFF13151A),
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)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF00E5FF),
),
),
),
)
else
TextButton.icon(
key: const ValueKey('route_planner_save_button'),
onPressed: _saveRoute,
icon: const Icon(Icons.save, color: Color(0xFF00E5FF), size: 18),
label: const Text(
'SAVE',
style: TextStyle(
color: Color(0xFF00E5FF),
fontWeight: FontWeight.bold,
),
),
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 360;
final isCompactHeight = constraints.maxHeight < 500;
final isVeryCompactHeight = constraints.maxHeight < 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 (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;
}
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<RouteVisibility>(
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,
),
],
),
);
},
),
),
],
);
},
),
);
}
}