1405 lines
45 KiB
Dart
1405 lines
45 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|