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

827 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart';
import '../../services/map/map_provider_interface.dart';
import '../../services/map/unified_map_view.dart';
import '../../services/routes/navigation_handoff.dart';
import '../../services/routes/route_models.dart';
import '../../services/routes/route_providers.dart';
class RouteDetailScreen extends ConsumerStatefulWidget {
final String routeId;
const RouteDetailScreen({super.key, required this.routeId});
@override
ConsumerState<RouteDetailScreen> createState() => _RouteDetailScreenState();
}
class _RouteDetailScreenState extends ConsumerState<RouteDetailScreen> {
bool _isDeleting = false;
Future<void> _confirmDelete(MotorSignalRoute route) async {
final colors = context.motorColors;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
title: const Text(
'Delete Route',
style: TextStyle(color: Colors.white),
),
content: Text(
'Are you sure you want to delete "${route.title}"? This cannot be undone.',
style: const TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text(
'CANCEL',
style: TextStyle(color: Colors.white54),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.raceRed,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('DELETE'),
),
],
),
);
if (confirm != true || !mounted) return;
setState(() => _isDeleting = true);
try {
final user = ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError('Sign-in required to delete route.');
}
final service = ref.read(routeServiceProvider);
await service.deleteRoute(
widget.routeId,
callerUid: user.uid,
);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Route deleted.')));
context.pop();
}
} catch (e) {
if (mounted) {
setState(() => _isDeleting = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to delete route: $e')));
}
}
}
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;
final routeAsync = ref.watch(routeByIdProvider(widget.routeId));
final currentUser = ref.watch(currentUserProvider);
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: const Text(
'ROUTE DETAILS',
style: TextStyle(
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontSize: 16,
),
),
),
body: routeAsync.when(
loading: () => Center(
child: CircularProgressIndicator(color: colors.cyberCyan),
),
error: (err, _) => Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: colors.raceRed,
),
const SizedBox(height: 12),
Text(
'Could not load route: $err',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
data: (route) {
if (route == null) {
return const Center(
child: Text(
'Route not found or has been deleted.',
style: TextStyle(color: Colors.white54),
),
);
}
final isOwner = currentUser?.uid == route.ownerUid;
// Map markers
final markers = route.stops.map((stop) {
final isFirst = stop.orderIndex == 0;
final isLast =
stop.orderIndex == route.stops.length - 1 &&
route.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();
// Polylines
final polylines = route.stops.length >= 2
? [
AppPolyline(
id: 'route_straight_line',
points: route.previewPoints
.map((p) => AppLatLng(p.latitude, p.longitude))
.toList(),
color: colors.cyberCyan,
width: 3.5,
),
]
: <AppPolyline>[];
final mapCenter = route.stops.isNotEmpty
? AppLatLng(
route.stops.first.latitude,
route.stops.first.longitude,
)
: const AppLatLng(37.7749, -122.4194);
return ResponsiveLayout(
builder: (context, snapshot) {
final isExpandedLayout =
snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720);
if (isExpandedLayout) {
return _buildExpandedLayout(
context,
colors,
route,
isOwner,
mapCenter,
markers,
polylines,
);
}
return _buildCompactLayout(
context,
colors,
snapshot,
route,
isOwner,
mapCenter,
markers,
polylines,
);
},
);
},
),
);
}
Widget _buildExpandedLayout(
BuildContext context,
MotorSignalThemeColors colors,
MotorSignalRoute route,
bool isOwner,
AppLatLng mapCenter,
List<AppMapMarker> markers,
List<AppPolyline> polylines,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Left Column: Map Preview + Metrics
Expanded(
flex: 5,
child: Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(color: colors.outlineVariant),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Map Preview Box
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 280,
width: double.infinity,
child: Stack(
children: [
UnifiedMapView(
key: ValueKey('route_detail_map_exp_${route.id}'),
center: mapCenter,
zoom: 12.0,
markers: markers,
polylines: polylines,
),
Positioned(
top: 8,
left: 8,
child: _buildVisibilityBadge(colors, route),
),
],
),
),
),
const SizedBox(height: 16),
_buildDistanceAndStopsCard(colors, route),
const SizedBox(height: 12),
_buildDisclaimerText(),
],
),
),
),
),
// Right Column: Details, Actions, Stops list
Expanded(
flex: 6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitleAndDescription(colors, route),
const SizedBox(height: 16),
if (isOwner) ...[
_buildOwnerActions(colors, route),
const SizedBox(height: 16),
],
if (route.attachedGroupRideId != null) ...[
_buildAttachedGroupRideButton(colors, route),
const SizedBox(height: 16),
],
_buildStopsHeader(),
const SizedBox(height: 10),
_buildStopsList(colors, route),
const SizedBox(height: 24),
if (route.stops.isNotEmpty) _buildNavigateStartButton(colors, route),
const SizedBox(height: 32),
],
),
),
),
],
);
}
Widget _buildCompactLayout(
BuildContext context,
MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot,
MotorSignalRoute route,
bool isOwner,
AppLatLng mapCenter,
List<AppMapMarker> markers,
List<AppPolyline> polylines,
) {
final double mapHeight = snapshot.height < 360
? 96
: (snapshot.height < 450 ? 140 : 220);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Map Preview
SizedBox(
height: mapHeight,
width: double.infinity,
child: Stack(
children: [
UnifiedMapView(
key: ValueKey('route_detail_map_compact_${route.id}'),
center: mapCenter,
zoom: 12.0,
markers: markers,
polylines: polylines,
),
Positioned(
top: 8,
left: 8,
child: _buildVisibilityBadge(colors, route),
),
],
),
),
// Title & Info
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitleAndDescription(colors, route),
const SizedBox(height: 12),
_buildDistanceAndStopsCard(colors, route),
const SizedBox(height: 8),
_buildDisclaimerText(),
const SizedBox(height: 16),
if (isOwner) ...[
_buildOwnerActions(colors, route),
const SizedBox(height: 16),
],
if (route.attachedGroupRideId != null) ...[
_buildAttachedGroupRideButton(colors, route),
const SizedBox(height: 16),
],
_buildStopsHeader(),
const SizedBox(height: 10),
_buildStopsList(colors, route),
const SizedBox(height: 24),
if (route.stops.isNotEmpty) _buildNavigateStartButton(colors, route),
const SizedBox(height: 32),
],
),
),
],
),
);
}
Widget _buildVisibilityBadge(MotorSignalThemeColors colors, MotorSignalRoute route) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: colors.surfaceContainer.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.outlineVariant),
),
child: Text(
route.visibility.label,
style: TextStyle(
color: colors.cyberCyan,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildTitleAndDescription(MotorSignalThemeColors colors, MotorSignalRoute route) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
route.title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
if (route.description.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
route.description,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
],
);
}
Widget _buildDistanceAndStopsCard(MotorSignalThemeColors colors, MotorSignalRoute route) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'STRAIGHT-LINE DISTANCE',
style: TextStyle(
color: Colors.white38,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
Text(
'${route.formattedDistanceMiles} (${route.formattedDistanceKm})',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
),
Container(
width: 1,
height: 32,
color: colors.outlineVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'WAYPOINTS',
style: TextStyle(
color: Colors.white38,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
Text(
'${route.stops.length} STOPS',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
);
}
Widget _buildDisclaimerText() {
return const Text(
'MotorSignal provides straight-line route planning and handoff. Turn-by-turn navigation opens in Google Maps or Waze.',
style: TextStyle(color: Colors.white38, fontSize: 11),
);
}
Widget _buildOwnerActions(MotorSignalThemeColors colors, MotorSignalRoute route) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
key: const ValueKey('route_detail_edit_button'),
onPressed: () {
context.push(
'/routes/planner?routeId=${route.id}',
);
},
icon: Icon(
Icons.edit,
size: 16,
color: colors.cyberCyan,
),
label: Text(
'EDIT ROUTE',
style: TextStyle(color: colors.cyberCyan),
),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: colors.cyberCyan,
),
padding: const EdgeInsets.symmetric(
vertical: 10,
),
),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
key: const ValueKey('route_detail_delete_button'),
onPressed: _isDeleting ? null : () => _confirmDelete(route),
icon: Icon(
Icons.delete_outline,
size: 16,
color: colors.raceRed,
),
label: Text(
'DELETE',
style: TextStyle(color: colors.raceRed),
),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: colors.raceRed,
),
padding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 16,
),
),
),
],
);
}
Widget _buildAttachedGroupRideButton(MotorSignalThemeColors colors, MotorSignalRoute route) {
return OutlinedButton.icon(
key: const ValueKey(
'route_detail_view_attached_group_ride_button',
),
onPressed: () {
context.push(
'/group-rides/${route.attachedGroupRideId}',
);
},
icon: Icon(
Icons.group,
size: 18,
color: colors.cyberCyan,
),
label: Text(
'VIEW ATTACHED GROUP RIDE',
style: TextStyle(color: colors.cyberCyan),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.outlineVariant),
minimumSize: const Size(double.infinity, 44),
),
);
}
Widget _buildStopsHeader() {
return const Text(
'STOPS & WAYPOINTS',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
);
}
Widget _buildStopsList(MotorSignalThemeColors colors, MotorSignalRoute route) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: route.stops.length,
itemBuilder: (context, index) {
final stop = route.stops[index];
final isFirst = index == 0;
final isLast =
index == route.stops.length - 1 &&
route.stops.length > 1;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.outlineVariant,
),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: isFirst
? colors.launchGreen
: isLast
? colors.raceRed
: colors.cyberCyan,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getIconForStopType(stop.stopType),
size: 14,
color: colors.cyberCyan,
),
const SizedBox(width: 4),
Flexible(
child: Text(
stop.displayName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 2),
Text(
'${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}',
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
if (stop.notes.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
stop.notes,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
ElevatedButton.icon(
onPressed: () {
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: stop.latitude,
longitude: stop.longitude,
displayName: stop.displayName,
);
},
icon: const Icon(Icons.navigation, size: 14),
label: const Text('NAV'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
},
);
}
Widget _buildNavigateStartButton(MotorSignalThemeColors colors, MotorSignalRoute route) {
return ElevatedButton.icon(
key: const ValueKey(
'route_detail_navigate_start_button',
),
onPressed: () {
final firstStop = route.stops.first;
NavigationHandoffService.showHandoffSheet(
context: context,
latitude: firstStop.latitude,
longitude: firstStop.longitude,
displayName: 'Start: ${firstStop.displayName}',
);
},
icon: const Icon(Icons.directions, size: 20),
label: const Text(
'NAVIGATE TO ROUTE START',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
);
}
}