// ignore_for_file: deprecated_member_use import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../models/navigation.dart'; import '../models/schedule.dart' as schedule; import '../services/navigation_service.dart'; import '../services/schedule_service.dart'; class NavigationScreen extends StatefulWidget { final schedule.Route route; const NavigationScreen({ super.key, required this.route, }); @override State createState() => _NavigationScreenState(); } class _NavigationScreenState extends State { final _navigationService = NavigationService(); final _scheduleService = ScheduleService(); late MapController _mapController; NavigationRoute? _currentRoute; NavigationPoint? _currentLocation; bool _isLoading = false; String? _errorMessage; List? _alternativeRoutes; List? _stops; String? _stopsFetchError; int _currentStopIndex = 0; final Set _completedStops = {}; @override void initState() { super.initState(); _mapController = MapController(); _initializeNavigation(); } Future _initializeNavigation() async { setState(() { _isLoading = true; _errorMessage = null; }); try { // Get current location final currentLoc = await _navigationService.getCurrentLocation(); setState(() => _currentLocation = currentLoc); // Fetch stops for the bus line from the API (if available) try { _stops = await _scheduleService.getStops(widget.route.busLine.id); _stopsFetchError = null; } catch (e) { _stops = []; _stopsFetchError = e.toString().replaceAll('Exception: ', ''); } // Determine start and end points: prefer stop locations if available. // If no stops are returned from the API, geocode the stop names. late NavigationPoint startPoint; late NavigationPoint endPoint; if (_stops != null && _stops!.length >= 2) { final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!); _stops = optimized.orderedStops; startPoint = _stops!.first; endPoint = _stops!.last; if (mounted) { setState(() { _currentRoute = optimized.route; _alternativeRoutes = null; _isLoading = false; }); _fitMapToRoute(optimized.route); } return; } else if (_stops != null && _stops!.length == 1) { // With exactly one linked stop, force that stop into the route as start // so the marker is guaranteed to be in-view. startPoint = _stops!.first; try { endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop); } catch (_) { endPoint = NavigationPoint( latitude: startPoint.latitude + 0.01, longitude: startPoint.longitude + 0.01, name: widget.route.busLine.endStop, ); } } else { // Try geocoding names; fall back to conservative defaults on failure try { startPoint = await _navigationService.geocodePlace(widget.route.busLine.startStop); } catch (_) { startPoint = NavigationPoint( latitude: 52.5200, longitude: 13.4050, name: widget.route.busLine.startStop, ); } try { endPoint = await _navigationService.geocodePlace(widget.route.busLine.endStop); } catch (_) { endPoint = NavigationPoint( latitude: 52.5300, longitude: 13.4150, name: widget.route.busLine.endStop, ); } } // Get route without waypoints final navRoute = await _navigationService.getRoute(start: startPoint, end: endPoint); // Get alternative routes final alternatives = await _navigationService.getMultipleRoutes(start: startPoint, end: endPoint); if (mounted) { setState(() { _currentRoute = navRoute; _alternativeRoutes = alternatives; _isLoading = false; }); // Fit map to route bounds _fitMapToRoute(navRoute); } } catch (e) { if (mounted) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); _isLoading = false; }); } } } void _fitMapToRoute(NavigationRoute route) { final allPoints = [ ...route.getAllPoints().map((point) => LatLng(point.latitude, point.longitude)), ...(_stops ?? []).map((stop) => LatLng(stop.latitude, stop.longitude)), if (_currentLocation != null) LatLng(_currentLocation!.latitude, _currentLocation!.longitude), ]; if (allPoints.isEmpty) return; double minLat = allPoints.first.latitude; double maxLat = allPoints.first.latitude; double minLon = allPoints.first.longitude; double maxLon = allPoints.first.longitude; for (final point in allPoints) { minLat = min(minLat, point.latitude); maxLat = max(maxLat, point.latitude); minLon = min(minLon, point.longitude); maxLon = max(maxLon, point.longitude); } final bounds = LatLngBounds( LatLng(minLat, minLon), LatLng(maxLat, maxLon), ); _mapController.fitBounds( bounds, options: const FitBoundsOptions(padding: EdgeInsets.all(100)), ); } int _nextUncompletedStopIndex([int startIndex = 0]) { if (_stops == null || _stops!.isEmpty) return 0; for (var index = startIndex; index < _stops!.length; index++) { if (!_completedStops.contains(index)) { return index; } } return _stops!.length - 1; } Future _refreshRouteFromIndex(int index) async { if (_stops == null || _stops!.isEmpty) return; if (index < 0 || index >= _stops!.length) return; setState(() { _isLoading = true; _errorMessage = null; _currentStopIndex = index; }); try { final remainingStops = _stops!.sublist(index); final navRoute = remainingStops.length >= 2 ? await _navigationService.getRouteThroughStops(stops: remainingStops) : _currentRoute; if (mounted) { setState(() { if (navRoute != null) { _currentRoute = navRoute; } _alternativeRoutes = null; _isLoading = false; }); if (navRoute != null) { _fitMapToRoute(navRoute); } } } catch (e) { if (mounted) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); _isLoading = false; }); } } } Future _focusStop(int index) async { if (_stops == null || _stops!.isEmpty) return; if (index < 0 || index >= _stops!.length) return; await _refreshRouteFromIndex(index); } Future _markStopCompleted(int index) async { if (_stops == null || _stops!.isEmpty) return; if (index < 0 || index >= _stops!.length) return; setState(() { _completedStops.add(index); }); final nextIndex = _nextUncompletedStopIndex(index + 1); await _refreshRouteFromIndex(nextIndex); } double get _routeProgress { if (_stops == null || _stops!.isEmpty) return 0; if (_stops!.length == 1) return 1; return _currentStopIndex / (_stops!.length - 1); } Color get _routeColor => Color.lerp(Colors.blue, Colors.orange, _routeProgress) ?? Colors.blue; Color _stopColor(int index) { if (_completedStops.contains(index) || index < _currentStopIndex) return Colors.green; if (index == _currentStopIndex) return Colors.orange; return Colors.blueGrey; } IconData _stopIcon(int index) { if (_completedStops.contains(index) || index < _currentStopIndex) return Icons.check_circle; if (index == _currentStopIndex) return Icons.radio_button_checked; return Icons.location_on; } bool _isStopCompleted(int index) => _completedStops.contains(index) || index < _currentStopIndex; bool _hasStopNearPoint(NavigationPoint point, {double threshold = 0.00005}) { if (_stops == null || _stops!.isEmpty) return false; for (final stop in _stops!) { final latDiff = (stop.latitude - point.latitude).abs(); final lonDiff = (stop.longitude - point.longitude).abs(); if (latDiff <= threshold && lonDiff <= threshold) { return true; } } return false; } NavigationPoint? get _currentStopPoint { if (_stops == null || _stops!.isEmpty) return null; return _stops![_currentStopIndex.clamp(0, _stops!.length - 1)]; } NavigationPoint? get _nextStopPoint { if (_stops == null || _stops!.isEmpty) return null; final nextIndex = _nextUncompletedStopIndex(_currentStopIndex + 1); return _stops![nextIndex.clamp(0, _stops!.length - 1)]; } double get _completionRatio { if (_stops == null || _stops!.isEmpty) return 0; return _completedStops.length / _stops!.length; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Route ${widget.route.busLine.lineNumber} Navigation'), elevation: 0, ), body: Row( children: [ // Map area (left pane) Expanded( flex: 6, child: _currentRoute != null ? FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: LatLng( _currentRoute!.start.latitude, _currentRoute!.start.longitude, ), initialZoom: 13.0, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.example.bus_terminal', ), MarkerLayer( markers: [ ...(_stops ?? []).asMap().entries.map((entry) { final index = entry.key; final stop = entry.value; final color = _stopColor(index); return Marker( point: LatLng(stop.latitude, stop.longitude), width: 46, height: 46, child: Container( decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 3, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.22), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Stack( alignment: Alignment.center, children: [ Icon( _stopIcon(index), color: Colors.white, size: 20, ), Positioned( bottom: 4, child: Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: Colors.black.withOpacity(0.35), borderRadius: BorderRadius.circular(999), ), child: Text( '${index + 1}', style: const TextStyle( color: Colors.white, fontSize: 9, fontWeight: FontWeight.w800, ), ), ), ), ], ), ), ); }), // Start marker if (!_hasStopNearPoint(_currentRoute!.start)) Marker( point: LatLng( _currentRoute!.start.latitude, _currentRoute!.start.longitude, ), width: 40, height: 40, child: Container( decoration: BoxDecoration( color: Colors.green, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), child: const Icon( Icons.location_on, color: Colors.white, size: 20, ), ), ), // End marker if (!_hasStopNearPoint(_currentRoute!.end)) Marker( point: LatLng( _currentRoute!.end.latitude, _currentRoute!.end.longitude, ), width: 40, height: 40, child: Container( decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), child: const Icon( Icons.location_on, color: Colors.white, size: 20, ), ), ), // Current location marker if (_currentLocation != null) Marker( point: LatLng( _currentLocation!.latitude, _currentLocation!.longitude, ), width: 30, height: 30, child: Container( decoration: BoxDecoration( color: Colors.blue, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), ), child: const Icon( Icons.directions_bus, color: Colors.white, size: 16, ), ), ), ], ), PolylineLayer( polylines: [ // Main route Polyline( points: _currentRoute! .getAllPoints() .map((p) => LatLng(p.latitude, p.longitude)) .toList(), color: _routeColor, strokeWidth: 5, ), // Alternative routes (lighter) if (_alternativeRoutes != null) ...List.generate( _alternativeRoutes!.length, (i) => Polyline( points: _alternativeRoutes![i] .getAllPoints() .map((p) => LatLng(p.latitude, p.longitude)) .toList(), color: Colors.grey.withOpacity(0.5), strokeWidth: 2, ), ), ], ), ], ) : (_isLoading ? const Center(child: CircularProgressIndicator()) : Center( child: _errorMessage != null ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.error_outline, size: 64, color: Colors.red[300], ), const SizedBox(height: 16), Text( 'Failed to load route', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( _errorMessage!, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ], ) : const Text('No route'))), ), // Vertical divider Container(width: 1, color: Colors.grey[200]), // Stops / Info panel (right pane) Expanded( flex: 4, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), bottomLeft: Radius.circular(16), ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 10, offset: const Offset(-2, 0), ), ], ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(18), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.12), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: Colors.white.withOpacity(0.18), shape: BoxShape.circle, ), child: const Icon( Icons.route, color: Colors.white, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Route ${widget.route.busLine.lineNumber}', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 2), Text( '${widget.route.busLine.startStop} → ${widget.route.busLine.endStop}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white.withOpacity(0.85), ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( minHeight: 10, value: _completionRatio, backgroundColor: Colors.white.withOpacity(0.12), valueColor: const AlwaysStoppedAnimation(Colors.amberAccent), ), ), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${(_completionRatio * 100).round()}% afgerond', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), Text( '${_completedStops.length}/${_stops?.length ?? 0} stops', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white.withOpacity(0.85), ), ), ], ), const SizedBox(height: 12), Row( children: [ Expanded( child: _buildSummaryPill( 'Huidig', _currentStopPoint?.name ?? 'Geen stop', Icons.radio_button_checked, ), ), const SizedBox(width: 8), Expanded( child: _buildSummaryPill( 'Volgend', _nextStopPoint?.name ?? 'Einde route', Icons.skip_next, ), ), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFF0F172A), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), onPressed: _stops == null || _stops!.isEmpty || _isStopCompleted(_currentStopIndex) ? null : () => _markStopCompleted(_currentStopIndex), icon: const Icon(Icons.check_circle_outline), label: const Text('Markeer huidige stop afgerond'), ), ), ], ), ), const SizedBox(height: 14), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(12), ), child: Row( children: [ _legendDot(Colors.green, 'Afgerond'), const SizedBox(width: 12), _legendDot(Colors.orange, 'Actief'), const SizedBox(width: 12), _legendDot(Colors.blueGrey, 'Te doen'), ], ), ), if (_stopsFetchError != null) ...[ const SizedBox(height: 10), Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.red.withOpacity(0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.red.withOpacity(0.25)), ), child: Text( 'Stops konden niet geladen worden voor buslijn ${widget.route.busLine.id}: $_stopsFetchError', style: Theme.of(context).textTheme.bodySmall, ), ), ], const SizedBox(height: 12), Text( 'Stops', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Expanded( child: _stops == null || _stops!.isEmpty ? Center( child: Text( 'No stops available for this line', style: Theme.of(context).textTheme.bodySmall, ), ) : ListView.separated( itemCount: _stops!.length, separatorBuilder: (_, __) => const Divider(), itemBuilder: (context, index) { final s = _stops![index]; final color = _stopColor(index); final completed = _isStopCompleted(index); final isCurrent = index == _currentStopIndex; return ListTile( leading: CircleAvatar( radius: 14, backgroundColor: color, child: Icon( _stopIcon(index), color: Colors.white, size: 16, ), ), title: Text(s.name ?? 'Stop ${index + 1}'), subtitle: Text('${s.latitude.toStringAsFixed(5)}, ${s.longitude.toStringAsFixed(5)}'), trailing: completed ? const Chip( label: Text('Afgerond'), visualDensity: VisualDensity.compact, ) : TextButton.icon( onPressed: () => _markStopCompleted(index), icon: const Icon(Icons.check, size: 18), label: Text(isCurrent ? 'Afronden' : 'Klaarzetten'), ), onTap: () => _focusStop(index), ); }, ), ), ], ), ), ), ), ], ), ); } Widget _legendDot(Color color, String label) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration( color: color, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( label, style: Theme.of(context).textTheme.bodySmall, ), ], ); } Widget _buildSummaryPill(String title, String value, IconData icon) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white.withOpacity(0.12), borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white.withOpacity(0.16)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: Colors.white, size: 18), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Colors.white.withOpacity(0.75), letterSpacing: 0.2, ), ), const SizedBox(height: 4), Text( value, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ); } }