Update readme's, branches and actions
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

This commit is contained in:
Sven laptop
2026-09-17 12:04:44 +02:00
parent fdbdb98b21
commit 9d1a37e248
158 changed files with 428 additions and 176 deletions
View File
@@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart' as schedule;
import '../services/schedule_service.dart';
import '../services/auth_service.dart';
import '../services/exceptions.dart';
import '../widgets/route_card.dart';
class DailyScheduleScreen extends StatefulWidget {
const DailyScheduleScreen({super.key});
@override
State<DailyScheduleScreen> createState() => _DailyScheduleScreenState();
}
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<schedule.DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getTodaySchedule();
}
Future<void> _handleLogout() async {
await _authService.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
Future<void> _refreshSchedule() async {
setState(() {
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
});
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 7)),
lastDate: DateTime.now().add(const Duration(days: 30)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
_refreshSchedule();
}
}
// Group routes by hour for better visual organization
Map<int, List<schedule.Route>> _groupByHour(List<schedule.Route> routes) {
final grouped = <int, List<schedule.Route>>{};
for (final route in routes) {
final hour = route.startTime.hour;
grouped.putIfAbsent(hour, () => []).add(route);
}
return grouped;
}
void _moveDay(int deltaDays) {
setState(() {
_selectedDate = _selectedDate.add(Duration(days: deltaDays));
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
final today = DateTime.now();
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
DateTime(today.year, today.month, today.day);
return Scaffold(
appBar: AppBar(
title: const Text('Today on the Road'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Logout',
onPressed: _handleLogout,
),
],
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Vandaag' : 'Geplande datum',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.78),
),
),
const SizedBox(height: 6),
Text(
dateStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Routes, stops en status in één overzicht',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.84),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: () => _moveDay(-1),
icon: const Icon(Icons.chevron_left, size: 18),
label: const Text('Vorige dag'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Kies datum'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: () => _moveDay(1),
icon: const Icon(Icons.chevron_right, size: 18),
label: const Text('Volgende dag'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
],
),
),
],
),
),
Expanded(
child: FutureBuilder<schedule.DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
// Auto-logout on session expiry
if (snapshot.error is UnauthorizedException) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLogout();
});
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
snapshot.error is UnauthorizedException
? 'Session Expired'
: 'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.error is UnauthorizedException
? 'Redirecting to login...'
: snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
if (snapshot.error is! UnauthorizedException) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
],
),
),
);
}
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'Geen actieve ritten',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Je hebt geen geplande ritten voor deze dag',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
final routes = [...snapshot.data!.routes]
..sort((a, b) => a.startTime.compareTo(b.startTime));
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
// Merge and group by hour
final allActiveRoutes = [...inProgressRoutes, ...scheduledRoutes]
..sort((a, b) => a.startTime.compareTo(b.startTime));
final hourGroups = _groupByHour(allActiveRoutes);
final sortedHours = hourGroups.keys.toList()..sort();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
if (allActiveRoutes.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
'Geen actieve ritten voor deze dag',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
...sortedHours.map((hour) {
final routesForHour = hourGroups[hour] ?? [];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
child: Row(
children: [
Text(
'${hour.toString().padLeft(2, '0')}:00 uur',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant,
),
),
],
),
),
...routesForHour.map((route) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: RouteCard(route: route, onTap: () {}),
)),
const SizedBox(height: 4),
],
);
}),
const SizedBox(height: 8),
],
),
);
},
),
),
],
),
),
);
}
}
+314
View File
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _userCodeController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_userCodeController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin',
error: 'User code must be a number',
);
if (mounted) {
setState(() {
_errorMessage = 'User code must be a number';
});
}
return;
}
await _authService.login(
userCode: parsedUserCode,
password: _passwordController.text,
);
if (mounted) {
// Navigate to daily schedule screen
Navigator.of(context).pushReplacementNamed('/schedule');
}
} catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
String? _validateUserCode(String? value) {
final v = value?.trim();
if (v?.isEmpty ?? true) {
return 'User code is required';
}
if (int.tryParse(v!) == null) {
return 'User code must be a number';
}
return null;
}
String? _validatePassword(String? value) {
if (value?.isEmpty ?? true) {
return 'Password is required';
}
if ((value?.length ?? 0) < 3) {
return 'Password must be at least 3 characters';
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.18),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.16),
shape: BoxShape.circle,
),
child: const Icon(
Icons.directions_bus,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bus Terminal',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Driver access dashboard',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.82),
),
),
],
),
),
],
),
),
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.92),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outline.withOpacity(0.14)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Driver Login',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'Sign in to view your route, stops and live navigation.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.error.withOpacity(0.12),
border: Border.all(color: colorScheme.error.withOpacity(0.35)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
),
),
const SizedBox(height: 20),
],
CustomInputField(
label: 'User Code',
hint: 'Enter your user code',
controller: _userCodeController,
validator: _validateUserCode,
),
const SizedBox(height: 20),
CustomInputField(
label: 'Password',
hint: 'Enter your password',
controller: _passwordController,
obscureText: true,
validator: _validatePassword,
),
const SizedBox(height: 28),
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
)
: const Text('Login'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statusPill(Icons.shield_outlined, 'Secure access'),
const SizedBox(width: 10),
_statusPill(Icons.route_outlined, 'Live route data'),
],
),
],
),
),
],
),
),
),
),
),
),
);
}
Widget _statusPill(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
);
}
}
@@ -0,0 +1,913 @@
// 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,
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,332 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import '../models/schedule.dart' as sched;
import '../services/schedule_service.dart';
class RecurringSchedulesScreen extends StatefulWidget {
const RecurringSchedulesScreen({super.key});
@override
State<RecurringSchedulesScreen> createState() => _RecurringSchedulesScreenState();
}
class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
final _scheduleService = ScheduleService();
late Future<List<sched.ServiceSchedule>> _schedulesFuture;
String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
final Set<int> _expandedLineIds = {};
@override
void initState() {
super.initState();
_loadRecurringSchedules();
}
void _loadRecurringSchedules() {
_schedulesFuture = _scheduleService.getRecurringSchedules();
}
String _formatWeekdays(List<int> weekdays) {
const dayLabels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
if (weekdays.isEmpty) return 'Eenmalig';
if (weekdays.length == 7) return 'Dagelijks';
final sorted = weekdays.toSet().toList()..sort((a, b) => a.compareTo(b));
return sorted.map((d) => dayLabels[d]).join(', ');
}
String _formatTime(DateTime dt) {
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
List<sched.ServiceSchedule> _sortSchedules(List<sched.ServiceSchedule> schedules) {
final sorted = List<sched.ServiceSchedule>.from(schedules);
switch (_selectedSortBy) {
case 'line':
sorted.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
break;
case 'time':
sorted.sort((a, b) => a.startTime.compareTo(b.startTime));
break;
case 'weekday':
sorted.sort((a, b) {
final aFirst = a.weekdays.isEmpty ? 7 : a.weekdays.first;
final bFirst = b.weekdays.isEmpty ? 7 : b.weekdays.first;
return aFirst.compareTo(bFirst);
});
break;
}
return sorted;
}
Map<int, List<sched.ServiceSchedule>> _groupByLine(List<sched.ServiceSchedule> schedules) {
final grouped = <int, List<sched.ServiceSchedule>>{};
for (final schedule in schedules) {
grouped.putIfAbsent(schedule.lineNumber, () => []).add(schedule);
}
return grouped;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Vaste Diensten'),
centerTitle: true,
actions: [
PopupMenuButton<String>(
onSelected: (value) {
setState(() => _selectedSortBy = value);
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'line',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sort_by_alpha, color: _selectedSortBy == 'line' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Lijn'),
],
),
),
PopupMenuItem(
value: 'time',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, color: _selectedSortBy == 'time' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Tijd'),
],
),
),
PopupMenuItem(
value: 'weekday',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, color: _selectedSortBy == 'weekday' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Weekdag'),
],
),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Icon(Icons.filter_list),
),
),
],
),
body: FutureBuilder<List<sched.ServiceSchedule>>(
future: _schedulesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Fout bij laden: ${snapshot.error}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() => _loadRecurringSchedules());
},
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final allSchedules = snapshot.data ?? [];
// Filter to show only truly recurring schedules (with weekdays > 0)
final schedules = allSchedules.where((s) => s.weekdays.isNotEmpty).toList();
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text(
'Geen herhaalde diensten gevonden',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Herhaalde diensten worden hier weergegeven',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final sortedSchedules = _sortSchedules(schedules);
final groupedByLine = _groupByLine(sortedSchedules);
return ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemCount: groupedByLine.length,
itemBuilder: (context, lineIndex) {
final lineNumber = groupedByLine.keys.toList()[lineIndex];
final lineSchedules = groupedByLine[lineNumber] ?? [];
final isExpanded = _expandedLineIds.contains(lineNumber);
return Column(
children: [
Card(
child: InkWell(
onTap: () {
setState(() {
if (isExpanded) {
_expandedLineIds.remove(lineNumber);
} else {
_expandedLineIds.add(lineNumber);
}
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lijn $lineNumber',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${lineSchedules.length} diensten',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: colorScheme.primary,
),
],
),
),
),
),
if (isExpanded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: lineSchedules.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, scheduleIndex) {
final schedule = lineSchedules[scheduleIndex];
return _ScheduleCard(schedule: schedule, formatTime: _formatTime, formatWeekdays: _formatWeekdays);
},
),
),
const SizedBox(height: 8),
],
);
},
);
},
),
);
}
}
class _ScheduleCard extends StatelessWidget {
final sched.ServiceSchedule schedule;
final String Function(DateTime) formatTime;
final String Function(List<int>) formatWeekdays;
const _ScheduleCard({
required this.schedule,
required this.formatTime,
required this.formatWeekdays,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${formatTime(schedule.startTime)} - ${formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
schedule.busName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
label: Text(
formatWeekdays(schedule.weekdays),
style: Theme.of(context).textTheme.labelSmall,
),
avatar: const Icon(Icons.calendar_today, size: 16),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,674 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import '../models/schedule.dart' as sched;
import '../models/navigation.dart';
import '../services/auth_service.dart';
import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart';
import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key});
@override
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
}
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<List<sched.ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
late TabController _tabController;
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _handleLogout() async {
await _authService.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
Future<void> _refreshSchedules() async {
setState(() {
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
});
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 7)),
lastDate: DateTime.now().add(const Duration(days: 30)),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
_refreshSchedules();
}
}
Future<void> _showScheduleDetails(sched.ServiceSchedule svc) async {
try {
final stops = await _scheduleService.getScheduleStops(svc.id);
if (!mounted) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
minChildSize: 0.45,
maxChildSize: 0.95,
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
Text(
'Lijn ${svc.lineNumber}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
svc.busName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_formatTime(svc.startTime)} - ${_formatTime(svc.endTime)}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(label: svc.status),
_InfoChip(label: svc.isRecurring ? _weekdayLabel(svc.weekdays) : 'Eenmalig'),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
try {
await _scheduleService.updateScheduleStatus(
svc.id,
'in_progress',
date: svc.startTime,
);
if (!mounted) return;
Navigator.of(sheetContext).pop();
final routeObj = sched.Route(
id: svc.id,
busLine: sched.BusLine(
id: svc.busLineId,
lineNumber: svc.lineNumber,
startStop: '',
endStop: '',
estimatedDuration: 0,
),
busType: sched.BusType(
id: svc.busId,
name: svc.busName,
seatCapacity: 0,
licensePlate: '',
),
startTime: svc.startTime,
endTime: svc.endTime,
status: 'in_progress',
);
final navStops = stops
.map((s) => NavigationPoint(
latitude: s.latitude,
longitude: s.longitude,
name: s.name,
))
.toList();
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
),
);
} catch (navErr) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Status bijwerken mislukt: $e')),
);
}
},
icon: const Icon(Icons.navigation),
label: const Text('Start route'),
),
),
Text(
'Haltes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (stops.isEmpty)
Text(
'Geen haltes gekoppeld aan deze dienst.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
...stops.map((stop) {
final departureTime = svc.startTime.add(
Duration(minutes: stop.estimatedArrivalMinutes),
);
return Card(
child: ListTile(
dense: true,
title: Text('${stop.order}. ${stop.name}'),
subtitle: Text('${stop.estimatedArrivalMinutes} min na vertrek'),
trailing: Text(_formatTime(departureTime)),
),
);
}),
],
);
},
);
},
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kon dienstdetails niet laden: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Diensten'),
centerTitle: true,
elevation: 0,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
Tab(icon: Icon(Icons.timeline), text: 'Voortgang'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Logout',
onPressed: _handleLogout,
),
],
),
body: TabBarView(
controller: _tabController,
children: [
_DailyScheduleTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
onSelectDate: _selectDate,
onRefresh: _refreshSchedules,
onShowDetails: _showScheduleDetails,
scheduleFuture: _scheduleFuture,
),
const RecurringSchedulesScreen(),
_ProgressTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
scheduleFuture: _scheduleFuture,
),
],
),
);
}
}
class _ProgressTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _ProgressTab({
required this.scheduleService,
required this.selectedDate,
required this.scheduleFuture,
});
@override
Widget build(BuildContext context) {
final dateStr = '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Voortgang voor $dateStr', style: Theme.of(context).textTheme.titleLarge),
IconButton(
onPressed: () async {
// refresh handled by parent via reselecting date; show a simple feedback
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ververs de datum in Dagschema-tab om andere datum te laden')));
},
icon: const Icon(Icons.info_outline),
),
],
),
),
Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Fout bij laden: ${snapshot.error}'));
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) return Center(child: Text('Geen diensten op $dateStr'));
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: schedules.length,
itemBuilder: (context, index) {
final svc = schedules[index];
return Card(
child: ListTile(
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
subtitle: Text(_formatTime(svc.startTime)),
trailing: TextButton(
onPressed: () async {
try {
final progress = await scheduleService.getScheduleProgress(svc.id, selectedDate);
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final stops = (progress['stops'] as List<dynamic>?) ?? [];
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Voortgang — Lijn ${svc.lineNumber}', style: Theme.of(ctx).textTheme.headlineSmall),
const SizedBox(height: 8),
Text('Status: ${progress['schedule']?['status'] ?? 'onbekend'}'),
Text('Driver state: ${progress['driverState'] ?? 'onbekend'}'),
Text('Passed: ${progress['passedStopCount'] ?? 0} / ${progress['totalStopCount'] ?? 0}'),
const SizedBox(height: 12),
const Text('Haltes:'),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: stops.length,
itemBuilder: (c, i) {
final s = stops[i] as Map<String, dynamic>;
return ListTile(
dense: true,
title: Text('${s['stopOrder']}. ${s['stopName']}'),
subtitle: Text('Scheduled: ${s['scheduledPassedAt'] ?? '-'}\nActual: ${s['actualPassedAt'] ?? '-'}'),
);
},
),
),
],
),
);
},
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e')));
}
},
child: const Text('Bekijk voortgang'),
),
),
);
},
);
},
),
),
],
);
}
}
class _DailyScheduleTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final VoidCallback onSelectDate;
final VoidCallback onRefresh;
final Function(sched.ServiceSchedule) onShowDetails;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _DailyScheduleTab({
required this.scheduleService,
required this.selectedDate,
required this.onSelectDate,
required this.onRefresh,
required this.onShowDetails,
required this.scheduleFuture,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Geplande diensten',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.78),
),
),
const SizedBox(height: 6),
Text(
dateStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.84),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: onSelectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Wijzig datum'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
),
],
),
),
Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: onRefresh,
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => onRefresh(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return _ScheduleSummaryCard(
schedule: schedule,
onTap: () => onShowDetails(schedule),
);
},
),
);
},
),
),
],
),
);
}
}
class _ScheduleSummaryCard extends StatelessWidget {
final sched.ServiceSchedule schedule;
final VoidCallback onTap;
const _ScheduleSummaryCard({
required this.schedule,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
elevation: 0,
color: colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colorScheme.primary, colorScheme.tertiary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Line ${schedule.lineNumber}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
// Only show status for one-time schedules; recurring schedules have no fixed status
if (!schedule.isRecurring)
_InfoChip(label: schedule.status),
],
),
const SizedBox(height: 16),
Text(
schedule.busName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Text(
schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalige dienst',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.list_alt, size: 18),
label: const Text('Haltes'),
),
),
],
),
),
),
);
}
}
class _InfoChip extends StatelessWidget {
final String label;
const _InfoChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
String _weekdayLabel(List<int> weekdays) {
const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
if (weekdays.isEmpty) {
return 'Eenmalige dienst';
}
if (weekdays.length == 7) {
return 'Dagelijks';
}
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
}