create admin_ui

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 10:55:31 +02:00
parent 2845f442a3
commit 8cf3fe98cb
40 changed files with 5534 additions and 693 deletions
+104 -22
View File
@@ -56,18 +56,65 @@ class RouteLeg {
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
String getDurationMinutes() => (duration ~/ 60).toString();
factory RouteLeg.fromJson(Map<String, dynamic> json) {
final coords = json['geometry']['coordinates'] as List;
factory RouteLeg.fromCoordinates(
List<dynamic> coordinates, {
double distance = 0.0,
int duration = 0,
String summary = 'Route',
}) {
return RouteLeg(
points: coords
.map((coord) => NavigationPoint(
latitude: coord[1] as double,
longitude: coord[0] as double,
))
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: (json['distance'] as num).toDouble(),
duration: (json['duration'] as num).toInt(),
summary: json['summary'] as String? ?? 'Route',
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromPoints({
required List<NavigationPoint> points,
required double distance,
required int duration,
String summary = 'Route',
}) {
return RouteLeg(
points: points,
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromJson(Map<String, dynamic> json) {
final geometry = json['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
return RouteLeg(
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: (json['distance'] as num?)?.toDouble() ?? 0.0,
duration: (json['duration'] as num?)?.toInt() ?? 0,
summary: (json['summary'] as String?) ?? 'Route',
);
}
}
@@ -108,22 +155,57 @@ class NavigationRoute {
}
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
final routes = json['routes'] as List;
if (routes.isEmpty) {
throw Exception('No route found');
final routes = json['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes[0] as Map<String, dynamic>;
final legs = (routeData['legs'] as List)
.map((leg) => RouteLeg.fromJson(leg as Map<String, dynamic>))
.toList();
final routeData = routes[0] as Map<String, dynamic>?;
if (routeData == null) {
throw Exception('Invalid route data');
}
return NavigationRoute.fromOsrmRouteData(
routeData,
start: NavigationPoint(latitude: 0, longitude: 0),
end: NavigationPoint(latitude: 0, longitude: 0),
);
}
factory NavigationRoute.fromOsrmRouteData(
Map<String, dynamic> routeData, {
required NavigationPoint start,
required NavigationPoint end,
}) {
final geometry = routeData['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (routeData['duration'] as num?)?.toInt() ?? 0;
final leg = RouteLeg.fromCoordinates(
coordinates,
distance: distance,
duration: duration,
summary: (routeData['summary'] as String?) ?? 'Route',
);
return NavigationRoute(
start: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
end: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
legs: legs,
totalDistance: (routeData['distance'] as num).toDouble(),
totalDuration: (routeData['duration'] as num).toInt(),
start: start,
end: end,
legs: [leg],
totalDistance: distance,
totalDuration: duration,
);
}
}
/// Result of an optimized trip calculation with reordered stops.
class OptimizedRouteResult {
final NavigationRoute route;
final List<NavigationPoint> orderedStops;
OptimizedRouteResult({
required this.route,
required this.orderedStops,
});
}
@@ -28,6 +28,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
@@ -36,7 +37,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: Colors.white,
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
@@ -53,236 +54,210 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
@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 isToday = DateTime(
_selectedDate.year,
_selectedDate.month,
_selectedDate.day,
) ==
DateTime(
DateTime.now().year,
DateTime.now().month,
DateTime.now().day,
);
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('Your Schedule'),
title: const Text('Today on the Road'),
elevation: 0,
),
body: Column(
children: [
// Date selector
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Today' : 'Scheduled Date',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey[600],
),
),
Text(
dateStr,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
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,
),
OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: const Text('Change'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
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: SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Wijzig'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
),
),
],
],
),
),
),
const Divider(),
Expanded(
child: FutureBuilder<DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Routes list
Expanded(
child: FutureBuilder<DailySchedule>(
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: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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(
'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,
),
],
),
),
);
}
final routes = snapshot.data!.routes;
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
final completedRoutes = routes.where((r) => r.status == 'completed').toList();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
if (inProgressRoutes.isNotEmpty) ...[
_sectionHeader('In Progress', colorScheme.tertiary),
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (scheduledRoutes.isNotEmpty) ...[
_sectionHeader('Scheduled', colorScheme.primary),
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (completedRoutes.isNotEmpty) ...[
_sectionHeader('Completed', colorScheme.secondary),
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
const SizedBox(height: 8),
Text(
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.hasData || snapshot.data!.routes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today,
size: 64,
color: Colors.grey[300],
),
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: Colors.grey[600],
),
),
],
),
);
}
final schedule = snapshot.data!;
final routes = schedule.routes;
// Separate routes by status
final scheduledRoutes = routes
.where((r) => r.status == 'scheduled')
.toList();
final inProgressRoutes = routes
.where((r) => r.status == 'in_progress')
.toList();
final completedRoutes = routes
.where((r) => r.status == 'completed')
.toList();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
children: [
// In Progress section
if (inProgressRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'In Progress',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
),
...inProgressRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
// Scheduled section
if (scheduledRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'Scheduled',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
...scheduledRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
// Completed section
if (completedRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'Completed',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
...completedRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
const SizedBox(height: 16),
],
),
);
},
},
),
),
],
),
),
);
}
Widget _sectionHeader(String title, Color color) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
+202 -93
View File
@@ -40,7 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
throw Exception('User code must be a number');
}
final response = await _authService.login(
await _authService.login(
userCode: parsedUserCode,
password: _passwordController.text,
);
@@ -85,105 +85,214 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Bus Terminal Login'),
elevation: 0,
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
// Title
Text(
'Driver Login',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Enter your credentials to continue',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 48),
// Error message
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
border: Border.all(color: Colors.red[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: Colors.red[700],
fontSize: 14,
),
),
),
if (_errorMessage != null) const SizedBox(height: 24),
// User Code Field
CustomInputField(
label: 'User Code',
hint: 'Enter your user code',
controller: _userCodeController,
validator: _validateUserCode,
),
const SizedBox(height: 24),
// Password Field
CustomInputField(
label: 'Password',
hint: 'Enter your password',
controller: _passwordController,
obscureText: true,
validator: _validatePassword,
),
const SizedBox(height: 40),
// Login Button - Large for touch accessibility
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56), // Large touch target
disabledBackgroundColor: Colors.grey[400],
),
child: _isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'Login',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
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,
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:ffi';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
+202 -23
View File
@@ -47,21 +47,25 @@ class NavigationService {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message']}');
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
final route = NavigationRoute.fromJson(data);
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
}
// Set start and end points properly
return NavigationRoute(
final routes = data['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes.first as Map<String, dynamic>;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: start,
end: end,
legs: route.legs,
totalDistance: route.totalDistance,
totalDuration: route.totalDuration,
);
} else {
throw Exception('Failed to get route: ${response.statusCode}');
@@ -71,6 +75,144 @@ class NavigationService {
}
}
/// Get a route that passes through the provided stops in the given order.
Future<NavigationRoute> getRouteThroughStops({
required List<NavigationPoint> stops,
}) async {
try {
if (stops.length < 2) {
throw Exception('At least two stops are required');
}
final coordinates = stops
.map((stop) => '${stop.longitude},${stop.latitude}')
.join(';');
final url =
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last';
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to get route: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
}
final routes = data['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes.first as Map<String, dynamic>;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: stops.first,
end: stops.last,
);
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Get an optimized trip through multiple stops.
/// Keeps the first and last stops fixed and lets OSRM order the intermediates.
Future<OptimizedRouteResult> getOptimizedRouteThroughStops({
required List<NavigationPoint> stops,
}) async {
try {
if (stops.length < 2) {
throw Exception('At least two stops are required');
}
final coordinates = stops
.map((stop) => '${stop.longitude},${stop.latitude}')
.join(';');
final url =
'https://router.project-osrm.org/trip/v1/car/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false';
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to get optimized route: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
if (data['code'] != 'Ok') {
throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}');
}
final trips = data['trips'] as List?;
if (trips == null || trips.isEmpty) {
throw Exception('No optimized trip found');
}
final trip = trips.first as Map<String, dynamic>;
final geometry = trip['geometry'] as Map<String, dynamic>?;
final coordinatesData = geometry?['coordinates'] as List? ?? [];
final points = coordinatesData
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList();
final waypointItems = (data['waypoints'] as List?) ?? [];
final orderedPairs = <MapEntry<int, NavigationPoint>>[];
for (var i = 0; i < waypointItems.length && i < stops.length; i++) {
final waypoint = waypointItems[i];
if (waypoint is! Map<String, dynamic>) continue;
final index = waypoint['waypoint_index'] as num?;
if (index == null) continue;
orderedPairs.add(MapEntry(index.toInt(), stops[i]));
}
orderedPairs.sort((a, b) => a.key.compareTo(b.key));
final orderedStops = orderedPairs.isNotEmpty
? orderedPairs.map((entry) => entry.value).toList()
: stops;
final route = NavigationRoute(
start: orderedStops.first,
end: orderedStops.last,
legs: [
RouteLeg.fromPoints(
points: points,
distance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
duration: (trip['duration'] as num?)?.toInt() ?? 0,
summary: 'Optimized trip',
),
],
totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
totalDuration: (trip['duration'] as num?)?.toInt() ?? 0,
);
return OptimizedRouteResult(
route: route,
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
);
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Get multiple routes (alternatives)
Future<List<NavigationRoute>> getMultipleRoutes({
required NavigationPoint start,
@@ -86,26 +228,30 @@ class NavigationService {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['code'] != 'Ok') {
throw Exception('Routes not found: ${data['message']}');
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
final routes = (data['routes'] as List)
if (data['code'] != 'Ok') {
throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}');
}
final routesData = data['routes'] as List?;
if (routesData == null || routesData.isEmpty) {
throw Exception('No routes found in response');
}
final routes = routesData
.map((routeData) {
return NavigationRoute(
if (routeData is! Map<String, dynamic>) return null;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: start,
end: end,
legs: (routeData['legs'] as List)
.map((leg) =>
RouteLeg.fromJson(leg as Map<String, dynamic>))
.toList(),
totalDistance:
(routeData['distance'] as num).toDouble(),
totalDuration: (routeData['duration'] as num).toInt(),
);
})
.whereType<NavigationRoute>()
.toList();
return routes;
@@ -125,4 +271,37 @@ class NavigationService {
LatLng(p2.latitude, p2.longitude),
);
}
/// Geocode a place name to coordinates using Nominatim (OpenStreetMap)
Future<NavigationPoint> geocodePlace(String name) async {
try {
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
'q': name,
'format': 'json',
'limit': '1',
});
final response = await http.get(uri, headers: {
'User-Agent': 'bus-terminal-app',
});
if (response.statusCode != 200) {
throw Exception('Geocoding failed: ${response.statusCode}');
}
final list = jsonDecode(response.body) as List<dynamic>?;
if (list == null || list.isEmpty) {
throw Exception('No geocoding result for "$name"');
}
final item = list[0] as Map<String, dynamic>;
final lat = double.parse(item['lat'] as String);
final lon = double.parse(item['lon'] as String);
final display = item['display_name'] as String? ?? name;
return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) {
throw Exception('Geocode error: $e');
}
}
}
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import '../models/navigation.dart';
import 'auth_service.dart';
/// Schedule/Route Service
@@ -82,4 +83,42 @@ class ScheduleService {
throw Exception('Status update error: $e');
}
}
/// Get stops for a bus line
Future<List<NavigationPoint>> getStops(int busLineId) async {
try {
final headers = await _authService.getAuthHeaders();
final response = await http.get(
Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'),
headers: headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final stops = (data['stops'] as List?)
?.map((s) => s as Map<String, dynamic>)
.map((s) {
final lat = (s['latitude'] ?? s['lat']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
if (lat == null || lon == null) return null;
return NavigationPoint(
latitude: lat.toDouble(),
longitude: lon.toDouble(),
name: name,
);
})
.whereType<NavigationPoint>()
.toList() ?? [];
return stops;
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else {
throw Exception('Failed to load stops: ${response.statusCode}');
}
} catch (e) {
throw Exception('Get stops error: $e');
}
}
}
+186 -6
View File
@@ -2,23 +2,203 @@ import 'package:flutter/material.dart';
/// Material 3 Theme configuratie voor bus_terminal
class AppTheme {
// TODO: Implement Material 3 theme for bus drivers
// - Large touch targets
// - High contrast
// - Clear visual hierarchy
static const Color _navy = Color(0xFF0F172A);
static const Color _surface = Color(0xFFF8FAFC);
static const Color _surfaceDark = Color(0xFF111827);
static const Color _amber = Color(0xFFF59E0B);
static const Color _cyan = Color(0xFF38BDF8);
static const Color _success = Color(0xFF22C55E);
static const Color _error = Color(0xFFEF4444);
static TextTheme _textTheme(ColorScheme colorScheme) {
return TextTheme(
displayLarge: const TextStyle(fontSize: 46, fontWeight: FontWeight.w800, letterSpacing: -1.2),
displayMedium: const TextStyle(fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: -0.9),
headlineLarge: const TextStyle(fontSize: 30, fontWeight: FontWeight.w800, letterSpacing: -0.4),
headlineMedium: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.3),
titleLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
titleMedium: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
titleSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: colorScheme.onSurface),
bodyMedium: TextStyle(fontSize: 14, height: 1.45, color: colorScheme.onSurfaceVariant),
bodySmall: TextStyle(fontSize: 12, height: 1.35, color: colorScheme.onSurfaceVariant),
labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
labelMedium: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
labelSmall: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.25),
);
}
static ColorScheme _buildLightScheme() {
return ColorScheme.fromSeed(
seedColor: _cyan,
brightness: Brightness.light,
primary: _navy,
secondary: _amber,
tertiary: _cyan,
surface: _surface,
onSurface: _navy,
error: _error,
).copyWith(
surfaceContainerHighest: const Color(0xFFE2E8F0),
outline: const Color(0xFF94A3B8),
);
}
static ColorScheme _buildDarkScheme() {
return ColorScheme.fromSeed(
seedColor: _cyan,
brightness: Brightness.dark,
primary: _cyan,
secondary: _amber,
tertiary: _success,
surface: _surfaceDark,
onSurface: Colors.white,
error: _error,
).copyWith(
surfaceContainerHighest: const Color(0xFF1F2937),
outline: const Color(0xFF334155),
);
}
static ThemeData lightTheme() {
final colorScheme = _buildLightScheme();
return ThemeData(
useMaterial3: true,
// TODO: Configure Material 3 theme
brightness: Brightness.light,
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFFF1F5F9),
textTheme: _textTheme(colorScheme),
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: colorScheme.onSurface),
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
margin: EdgeInsets.zero,
),
dividerTheme: DividerThemeData(color: colorScheme.outline.withOpacity(0.18), thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant.withOpacity(0.8)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.secondary,
foregroundColor: colorScheme.onSecondary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
minimumSize: const Size.fromHeight(52),
side: BorderSide(color: colorScheme.outline.withOpacity(0.35)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
chipTheme: ChipThemeData(
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.primary.withOpacity(0.12),
),
);
}
static ThemeData darkTheme() {
final colorScheme = _buildDarkScheme();
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
// TODO: Configure Material 3 dark theme
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFF0B1220),
textTheme: _textTheme(colorScheme).apply(bodyColor: Colors.white, displayColor: Colors.white),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF0B1220),
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: Colors.white),
),
cardTheme: CardThemeData(
color: const Color(0xFF111827),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(color: Color(0xFF243244), thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF111827),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
hintStyle: const TextStyle(color: Color(0xFF94A3B8)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.black,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.secondary,
foregroundColor: Colors.black,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
side: const BorderSide(color: Color(0xFF334155)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
chipTheme: ChipThemeData(
backgroundColor: const Color(0xFF1E293B),
labelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.primary.withOpacity(0.16),
),
);
}
}
@@ -37,6 +37,8 @@ class _CustomInputFieldState extends State<CustomInputField> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -54,13 +56,30 @@ class _CustomInputFieldState extends State<CustomInputField> {
validator: widget.validator,
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: Theme.of(context).textTheme.bodyMedium,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16, // Larger vertical padding for touch targets
),
prefixIcon: Icon(
widget.obscureText ? Icons.lock_outline : Icons.badge_outlined,
color: colorScheme.primary,
),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
suffixIcon: widget.obscureText
? GestureDetector(
@@ -70,6 +89,7 @@ class _CustomInputFieldState extends State<CustomInputField> {
child: Icon(
_obscureText ? Icons.visibility_off : Icons.visibility,
size: 28, // Larger icon for accessibility
color: colorScheme.primary,
),
)
: null,
+14 -6
View File
@@ -49,6 +49,7 @@ class RouteCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final statusColor = _getStatusColor(route.status);
final startTimeStr = _formatTime(route.startTime);
@@ -63,6 +64,9 @@ class RouteCard extends StatelessWidget {
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 0,
color: colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -77,7 +81,11 @@ class RouteCard extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue,
gradient: LinearGradient(
colors: [colorScheme.primary, colorScheme.tertiary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
@@ -94,7 +102,7 @@ class RouteCard extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
color: statusColor.withOpacity(0.14),
border: Border.all(color: statusColor),
borderRadius: BorderRadius.circular(20),
),
@@ -134,7 +142,7 @@ class RouteCard extends StatelessWidget {
),
Icon(
Icons.arrow_forward,
color: Colors.grey[400],
color: colorScheme.outline,
),
Expanded(
child: Column(
@@ -181,7 +189,7 @@ class RouteCard extends StatelessWidget {
Text(
route.busType.licensePlate,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -199,14 +207,14 @@ class RouteCard extends StatelessWidget {
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
color: colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${route.busLine.estimatedDuration} min duration',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
color: colorScheme.onSurfaceVariant,
),
),
],