update scheduler

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 18:09:12 +02:00
parent 68490f8fd5
commit d81da9a475
9 changed files with 1430 additions and 56 deletions
+28 -3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/daily_schedule_screen.dart';
import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart';
import 'theme/app_theme.dart';
void main() {
@@ -19,9 +20,33 @@ class MainApp extends StatelessWidget {
themeMode: ThemeMode.system,
routes: {
'/login': (context) => const LoginScreen(),
'/schedule': (context) => const DailyScheduleScreen(),
'/schedule': (context) => const ScheduleOverviewScreen(),
},
home: const _InitialRoute(),
);
}
}
/// Initial route checks if user is logged in
class _InitialRoute extends StatelessWidget {
const _InitialRoute();
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: AuthService().isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// If logged in, go to schedule; otherwise go to login
return snapshot.data == true
? const ScheduleOverviewScreen()
: const LoginScreen();
},
home: const LoginScreen(),
);
}
}
+80
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
/// Bus Line model
class BusLine {
final int id;
@@ -140,3 +142,81 @@ class DailySchedule {
};
}
}
/// Stop model for the new schedule system.
class ScheduleStop {
final int id;
final int order;
final String name;
final double latitude;
final double longitude;
final int estimatedArrivalMinutes;
ScheduleStop({
required this.id,
required this.order,
required this.name,
required this.latitude,
required this.longitude,
required this.estimatedArrivalMinutes,
});
factory ScheduleStop.fromJson(Map<String, dynamic> json) {
return ScheduleStop(
id: (json['id'] as num).toInt(),
order: (json['order'] as num).toInt(),
name: (json['name'] ?? json['stopName'] ?? '') as String,
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
estimatedArrivalMinutes: (json['estimatedArrivalMinutes'] as num?)?.toInt() ?? 0,
);
}
}
/// Schedule model for the new public schedule API.
class ServiceSchedule {
final int id;
final int busLineId;
final int busId;
final DateTime startTime;
final DateTime endTime;
final String status;
final int lineNumber;
final String busName;
final List<int> weekdays;
ServiceSchedule({
required this.id,
required this.busLineId,
required this.busId,
required this.startTime,
required this.endTime,
required this.status,
required this.lineNumber,
required this.busName,
required this.weekdays,
});
factory ServiceSchedule.fromJson(Map<String, dynamic> json) {
final rawWeekdays = json['weekdays'];
final weekdays = rawWeekdays is List
? rawWeekdays.map((day) => (day as num).toInt()).toList()
: rawWeekdays is String
? (jsonDecode(rawWeekdays) as List).map((day) => (day as num).toInt()).toList()
: <int>[];
return ServiceSchedule(
id: (json['id'] as num).toInt(),
busLineId: (json['busLineId'] as num).toInt(),
busId: (json['busId'] as num).toInt(),
startTime: DateTime.parse(json['startTime'] as String),
endTime: DateTime.parse(json['endTime'] as String),
status: (json['status'] ?? 'planned') as String,
lineNumber: (json['lineNumber'] as num).toInt(),
busName: (json['busName'] ?? '') as String,
weekdays: weekdays,
);
}
bool get isRecurring => weekdays.isNotEmpty;
}
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart';
import '../services/schedule_service.dart';
import '../services/auth_service.dart';
import '../services/exceptions.dart';
import '../widgets/route_card.dart';
class DailyScheduleScreen extends StatefulWidget {
@@ -12,6 +14,7 @@ class DailyScheduleScreen extends StatefulWidget {
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@@ -21,6 +24,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
_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);
@@ -65,6 +75,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
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(
@@ -149,6 +166,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
}
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),
@@ -162,21 +186,27 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
),
const SizedBox(height: 16),
Text(
'Failed to load schedule',
snapshot.error is UnauthorizedException
? 'Session Expired'
: 'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
snapshot.error is UnauthorizedException
? 'Redirecting to login...'
: snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
if (snapshot.error is! UnauthorizedException) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
],
),
),
@@ -0,0 +1,466 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart';
import '../services/auth_service.dart';
import '../services/schedule_service.dart';
class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key});
@override
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
}
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<List<ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
}
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(ServiceSchedule schedule) async {
try {
final stops = await _scheduleService.getScheduleStops(schedule.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 ${schedule.lineNumber}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
schedule.busName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(label: schedule.status),
_InfoChip(label: schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalig'),
],
),
const SizedBox(height: 20),
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 = schedule.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) {
final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
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(
'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: _selectDate,
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<ServiceSchedule>>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
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(
'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedules,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
final schedules = snapshot.data ?? [];
if (schedules.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(
'No routes scheduled',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Check back later for your schedule',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _refreshSchedules,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return _ScheduleSummaryCard(
schedule: schedule,
onTap: () => _showScheduleDetails(schedule),
);
},
),
);
},
),
),
],
),
),
);
}
}
class _ScheduleSummaryCard extends StatelessWidget {
final 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,
),
),
),
_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(', ');
}
@@ -0,0 +1,9 @@
/// Custom exception for authentication errors
class UnauthorizedException implements Exception {
final String message;
UnauthorizedException(this.message);
@override
String toString() => message;
}
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import '../models/navigation.dart';
import 'auth_service.dart';
import 'exceptions.dart';
/// Schedule/Route Service
class ScheduleService {
@@ -11,6 +12,51 @@ class ScheduleService {
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
final _authService = AuthService();
/// Get public schedules for a specific date.
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
try {
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final queryParameters = <String, String>{'date': dateStr};
if (lineId != null) {
queryParameters['lineId'] = lineId.toString();
}
final uri = Uri.parse('$_apiBaseUrl/schedules').replace(queryParameters: queryParameters);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['schedules'] as List? ?? [])
.map((schedule) => schedule as Map<String, dynamic>)
.map(ServiceSchedule.fromJson)
.toList();
}
throw Exception('Failed to load schedules: ${response.statusCode}');
} catch (e) {
throw Exception('Schedule error: $e');
}
}
/// Get stop list for a specific schedule.
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
try {
final response = await http.get(Uri.parse('$_apiBaseUrl/schedules/$scheduleId/stops'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['stops'] as List? ?? [])
.map((stop) => stop as Map<String, dynamic>)
.map(ScheduleStop.fromJson)
.toList();
}
throw Exception('Failed to load schedule stops: ${response.statusCode}');
} catch (e) {
throw Exception('Schedule stops error: $e');
}
}
/// Get daily schedule for a specific date
Future<DailySchedule> getDailySchedule(DateTime date) async {
try {
@@ -25,12 +71,14 @@ class ScheduleService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return DailySchedule.fromJson(data);
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else {
throw Exception('Failed to load schedule: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
throw Exception('Schedule error: $e');
}
}
@@ -54,12 +102,14 @@ class ScheduleService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return Route.fromJson(data);
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else {
throw Exception('Failed to load route: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
throw Exception('Route error: $e');
}
}
@@ -76,10 +126,14 @@ class ScheduleService {
body: jsonEncode({'status': status}),
);
if (response.statusCode != 200) {
if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
throw Exception('Status update error: $e');
}
}
@@ -112,12 +166,14 @@ class ScheduleService {
.whereType<NavigationPoint>()
.toList() ?? [];
return stops;
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else {
throw Exception('Failed to load stops: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
throw Exception('Get stops error: $e');
}
}