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
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:
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(', ');
|
||||
}
|
||||
Reference in New Issue
Block a user