9d1a37e248
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m8s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
914 lines
36 KiB
Dart
914 lines
36 KiB
Dart
// 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;
|
|
final List<NavigationPoint>? initialStops;
|
|
|
|
const NavigationScreen({
|
|
super.key,
|
|
required this.route,
|
|
this.initialStops,
|
|
});
|
|
|
|
@override
|
|
State<NavigationScreen> createState() => _NavigationScreenState();
|
|
}
|
|
|
|
class _NavigationScreenState extends State<NavigationScreen> {
|
|
final _navigationService = NavigationService();
|
|
final _scheduleService = ScheduleService();
|
|
late MapController _mapController;
|
|
|
|
NavigationRoute? _currentRoute;
|
|
NavigationPoint? _currentLocation;
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
List<NavigationRoute>? _alternativeRoutes;
|
|
List<NavigationPoint>? _stops;
|
|
String? _stopsFetchError;
|
|
int _currentStopIndex = 0;
|
|
final Set<int> _completedStops = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapController = MapController();
|
|
_initializeNavigation();
|
|
}
|
|
|
|
Future<void> _initializeNavigation() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
// Get current location
|
|
final currentLoc = await _navigationService.getCurrentLocation();
|
|
setState(() => _currentLocation = currentLoc);
|
|
|
|
// Use provided initial stops when available, otherwise fetch from API
|
|
if (widget.initialStops != null) {
|
|
_stops = widget.initialStops;
|
|
_stopsFetchError = null;
|
|
} else {
|
|
try {
|
|
_stops = await _scheduleService.getStops(widget.route.busLine.id);
|
|
_stopsFetchError = null;
|
|
} catch (e) {
|
|
_stops = [];
|
|
_stopsFetchError = e.toString().replaceAll('Exception: ', '');
|
|
}
|
|
}
|
|
|
|
// Defensive: filter out invalid stops (missing/NaN coordinates) and deduplicate
|
|
if (_stops != null) {
|
|
_stops = _stops!
|
|
.where((s) => s.latitude.isFinite && s.longitude.isFinite)
|
|
.toList();
|
|
|
|
// Remove exact duplicates by lat/lon
|
|
final seen = <String>{};
|
|
_stops = _stops!.where((s) {
|
|
final key = '${s.latitude.toStringAsFixed(6)}|${s.longitude.toStringAsFixed(6)}';
|
|
if (seen.contains(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
// 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 hasScheduleStops = widget.initialStops != null;
|
|
|
|
if (!hasScheduleStops) {
|
|
final optimized = await _navigationService.getOptimizedRouteThroughStops(stops: _stops!);
|
|
_stops = optimized.orderedStops;
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentRoute = optimized.route;
|
|
_alternativeRoutes = null;
|
|
_isLoading = false;
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(optimized.route));
|
|
}
|
|
return;
|
|
}
|
|
|
|
final navRoute = await _navigationService.getRouteThroughStops(stops: _stops!);
|
|
startPoint = _stops!.first;
|
|
endPoint = _stops!.last;
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentRoute = navRoute;
|
|
_alternativeRoutes = null;
|
|
_isLoading = false;
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
|
}
|
|
|
|
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 (but cap to a small number)
|
|
final alternatives = await _navigationService.getMultipleRoutes(start: startPoint, end: endPoint);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentRoute = navRoute;
|
|
_alternativeRoutes = alternatives.take(2).toList();
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Fit map to route bounds after first frame so MapController is attached
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _fitMapToRoute(NavigationRoute route) {
|
|
// Build list of points to include in bounds, filter invalids
|
|
final allPoints = <LatLng>[];
|
|
allPoints.addAll(route.getAllPoints().where((p) => p.latitude.isFinite && p.longitude.isFinite).map((point) => LatLng(point.latitude, point.longitude)));
|
|
allPoints.addAll((_stops ?? []).where((s) => s.latitude.isFinite && s.longitude.isFinite).map((stop) => LatLng(stop.latitude, stop.longitude)));
|
|
if (_currentLocation != null && _currentLocation!.latitude.isFinite && _currentLocation!.longitude.isFinite) {
|
|
allPoints.add(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);
|
|
}
|
|
|
|
// Clamp bounds to a reasonable maximum span to avoid huge map tiles requests
|
|
const double maxSpanDegrees = 2.0; // ~222km lat span per degree ~111km; 2 degrees ~222km
|
|
final latSpan = maxLat - minLat;
|
|
final lonSpan = maxLon - minLon;
|
|
|
|
double centerLat = (minLat + maxLat) / 2.0;
|
|
double centerLon = (minLon + maxLon) / 2.0;
|
|
|
|
double halfLat = latSpan / 2.0;
|
|
double halfLon = lonSpan / 2.0;
|
|
|
|
if (latSpan > maxSpanDegrees) halfLat = maxSpanDegrees / 2.0;
|
|
if (lonSpan > maxSpanDegrees) halfLon = maxSpanDegrees / 2.0;
|
|
|
|
final clampedMinLat = centerLat - halfLat;
|
|
final clampedMaxLat = centerLat + halfLat;
|
|
final clampedMinLon = centerLon - halfLon;
|
|
final clampedMaxLon = centerLon + halfLon;
|
|
|
|
final bounds = LatLngBounds(
|
|
LatLng(clampedMinLat, clampedMinLon),
|
|
LatLng(clampedMaxLat, clampedMaxLon),
|
|
);
|
|
|
|
_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<void> _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) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fitMapToRoute(navRoute));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _focusStop(int index) async {
|
|
if (_stops == null || _stops!.isEmpty) return;
|
|
if (index < 0 || index >= _stops!.length) return;
|
|
await _refreshRouteFromIndex(index);
|
|
}
|
|
|
|
Future<void> _markStopCompleted(int index) async {
|
|
if (_stops == null || _stops!.isEmpty) return;
|
|
if (index < 0 || index >= _stops!.length) return;
|
|
|
|
// When this screen is opened from schedule overview, route.id maps to scheduleId.
|
|
if (widget.initialStops != null) {
|
|
try {
|
|
await _scheduleService.markScheduleStopPassed(
|
|
scheduleId: widget.route.id,
|
|
stopOrder: index + 1,
|
|
actualPassedAt: DateTime.now(),
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = 'Stop update mislukt: ${e.toString().replaceAll('Exception: ', '')}';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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<Color>(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: (context, index) => 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|