Add Docker build workflow and mobile application

This commit is contained in:
Sven laptop
2026-05-05 17:39:02 +02:00
parent 39278dc857
commit 00758561f9
159 changed files with 8424 additions and 3 deletions
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/daily_schedule_screen.dart';
import 'theme/app_theme.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bus Terminal',
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.system,
routes: {
'/login': (context) => const LoginScreen(),
'/schedule': (context) => const DailyScheduleScreen(),
},
home: const LoginScreen(),
);
}
}
View File
+129
View File
@@ -0,0 +1,129 @@
import 'package:latlong2/latlong.dart';
/// Navigation route point
class NavigationPoint {
final double latitude;
final double longitude;
final String? name;
NavigationPoint({
required this.latitude,
required this.longitude,
this.name,
});
LatLng toLatLng() => LatLng(latitude, longitude);
factory NavigationPoint.fromLatLng(LatLng latlng, {String? name}) {
return NavigationPoint(
latitude: latlng.latitude,
longitude: latlng.longitude,
name: name,
);
}
Map<String, dynamic> toJson() {
return {
'latitude': latitude,
'longitude': longitude,
'name': name,
};
}
factory NavigationPoint.fromJson(Map<String, dynamic> json) {
return NavigationPoint(
latitude: json['latitude'] as double,
longitude: json['longitude'] as double,
name: json['name'] as String?,
);
}
}
/// Route leg with distance and duration
class RouteLeg {
final List<NavigationPoint> points;
final double distance; // in meters
final int duration; // in seconds
final String summary;
RouteLeg({
required this.points,
required this.distance,
required this.duration,
required this.summary,
});
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;
return RouteLeg(
points: coords
.map((coord) => NavigationPoint(
latitude: coord[1] as double,
longitude: coord[0] as double,
))
.toList(),
distance: (json['distance'] as num).toDouble(),
duration: (json['duration'] as num).toInt(),
summary: json['summary'] as String? ?? 'Route',
);
}
}
/// Complete navigation route from start to end
class NavigationRoute {
final NavigationPoint start;
final NavigationPoint end;
final List<RouteLeg> legs;
final double totalDistance; // in meters
final int totalDuration; // in seconds
NavigationRoute({
required this.start,
required this.end,
required this.legs,
required this.totalDistance,
required this.totalDuration,
});
String getTotalDistanceKm() => (totalDistance / 1000).toStringAsFixed(1);
String getTotalDurationMinutes() => (totalDuration ~/ 60).toString();
String getTotalDurationHM() {
final hours = totalDuration ~/ 3600;
final minutes = (totalDuration % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
List<NavigationPoint> getAllPoints() {
final allPoints = <NavigationPoint>[];
for (final leg in legs) {
allPoints.addAll(leg.points);
}
return allPoints;
}
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
final routes = json['routes'] as List;
if (routes.isEmpty) {
throw Exception('No route found');
}
final routeData = routes[0] as Map<String, dynamic>;
final legs = (routeData['legs'] as List)
.map((leg) => RouteLeg.fromJson(leg as Map<String, dynamic>))
.toList();
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(),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
/// Bus Line model
class BusLine {
final int id;
final int lineNumber;
final String startStop;
final String endStop;
final int estimatedDuration; // in minutes
final String? description;
BusLine({
required this.id,
required this.lineNumber,
required this.startStop,
required this.endStop,
required this.estimatedDuration,
this.description,
});
factory BusLine.fromJson(Map<String, dynamic> json) {
return BusLine(
id: json['id'] as int,
lineNumber: json['lineNumber'] as int,
startStop: json['startStop'] as String,
endStop: json['endStop'] as String,
estimatedDuration: json['estimatedDuration'] as int,
description: json['description'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'lineNumber': lineNumber,
'startStop': startStop,
'endStop': endStop,
'estimatedDuration': estimatedDuration,
'description': description,
};
}
}
/// Bus type model
class BusType {
final int id;
final String name;
final int seatCapacity;
final String licensePlate;
BusType({
required this.id,
required this.name,
required this.seatCapacity,
required this.licensePlate,
});
factory BusType.fromJson(Map<String, dynamic> json) {
return BusType(
id: json['id'] as int,
name: json['name'] as String,
seatCapacity: json['seatCapacity'] as int,
licensePlate: json['licensePlate'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'seatCapacity': seatCapacity,
'licensePlate': licensePlate,
};
}
}
/// Route model for daily schedule
class Route {
final int id;
final BusLine busLine;
final BusType busType;
final DateTime startTime;
final DateTime? endTime;
final String status; // scheduled, in_progress, completed, cancelled
Route({
required this.id,
required this.busLine,
required this.busType,
required this.startTime,
this.endTime,
required this.status,
});
factory Route.fromJson(Map<String, dynamic> json) {
return Route(
id: json['id'] as int,
busLine: BusLine.fromJson(json['busLine'] as Map<String, dynamic>),
busType: BusType.fromJson(json['busType'] as Map<String, dynamic>),
startTime: DateTime.parse(json['startTime'] as String),
endTime:
json['endTime'] != null ? DateTime.parse(json['endTime'] as String) : null,
status: json['status'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'busLine': busLine.toJson(),
'busType': busType.toJson(),
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'status': status,
};
}
}
/// Daily schedule model
class DailySchedule {
final List<Route> routes;
final DateTime date;
DailySchedule({
required this.routes,
required this.date,
});
factory DailySchedule.fromJson(Map<String, dynamic> json) {
return DailySchedule(
routes: (json['routes'] as List)
.map((r) => Route.fromJson(r as Map<String, dynamic>))
.toList(),
date: DateTime.parse(json['date'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'routes': routes.map((r) => r.toJson()).toList(),
'date': date.toIso8601String(),
};
}
}
+46
View File
@@ -0,0 +1,46 @@
/// User model
class User {
final int id;
final String userCode;
final String? job;
User({
required this.id,
required this.userCode,
this.job,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
userCode: json['userCode'] as String,
job: json['job'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userCode': userCode,
'job': job,
};
}
}
/// Login response model
class AuthResponse {
final String token;
final User user;
AuthResponse({
required this.token,
required this.user,
});
factory AuthResponse.fromJson(Map<String, dynamic> json) {
return AuthResponse(
token: json['token'] as String,
user: User.fromJson(json['user'] as Map<String, dynamic>),
);
}
}
View File
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart';
import '../services/schedule_service.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();
late Future<DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getTodaySchedule();
}
Future<void> _refreshSchedule() async {
setState(() {
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
});
}
Future<void> _selectDate() async {
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: Colors.white,
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
_refreshSchedule();
}
}
@override
Widget build(BuildContext context) {
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,
);
return Scaffold(
appBar: AppBar(
title: const Text('Your Schedule'),
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,
),
),
],
),
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,
),
),
),
],
),
),
const Divider(),
// 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: Column(
mainAxisAlignment: MainAxisAlignment.center,
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,
),
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),
],
),
);
},
),
),
],
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../services/auth_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 response = await _authService.login(
userCode: _userCodeController.text.trim(),
password: _passwordController.text,
);
if (mounted) {
// Navigate to daily schedule screen
Navigator.of(context).pushReplacementNamed('/schedule');
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
String? _validateUserCode(String? value) {
if (value?.isEmpty ?? true) {
return 'User code is required';
}
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) {
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,
),
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,438 @@
// 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';
class NavigationScreen extends StatefulWidget {
final schedule.Route route;
const NavigationScreen({
super.key,
required this.route,
});
@override
State<NavigationScreen> createState() => _NavigationScreenState();
}
class _NavigationScreenState extends State<NavigationScreen> {
final _navigationService = NavigationService();
late MapController _mapController;
NavigationRoute? _currentRoute;
NavigationPoint? _currentLocation;
bool _isLoading = false;
String? _errorMessage;
List<NavigationRoute>? _alternativeRoutes;
@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);
// For demo: use the bus line start stop coordinates
// In production, fetch actual GPS coordinates for stops
final startPoint = NavigationPoint(
latitude: 52.5200,
longitude: 13.4050,
name: widget.route.busLine.startStop,
);
final endPoint = NavigationPoint(
latitude: 52.5300,
longitude: 13.4150,
name: widget.route.busLine.endStop,
);
// Get route
final navRoute = await _navigationService.getRoute(
start: startPoint,
end: endPoint,
);
// Get alternative routes
final alternatives = await _navigationService.getMultipleRoutes(
start: startPoint,
end: endPoint,
);
if (mounted) {
setState(() {
_currentRoute = navRoute;
_alternativeRoutes = alternatives;
_isLoading = false;
});
// Fit map to route bounds
_fitMapToRoute(navRoute);
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_isLoading = false;
});
}
}
}
void _fitMapToRoute(NavigationRoute route) {
final allPoints = route.getAllPoints();
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);
}
final bounds = LatLngBounds(
LatLng(minLat, minLon),
LatLng(maxLat, maxLon),
);
_mapController.fitBounds(
bounds,
options: const FitBoundsOptions(padding: EdgeInsets.all(100)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Route ${widget.route.busLine.lineNumber} Navigation'),
elevation: 0,
),
body: Stack(
children: [
// Map
if (_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: [
// Start marker
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
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: Colors.blue,
strokeWidth: 4,
),
// 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,
),
),
],
),
],
)
else if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_errorMessage != null)
Center(
child: 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,
),
],
),
),
// Route info panel
if (_currentRoute != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Route title
Text(
'Route Summary',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Route info row
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: [
Icon(
Icons.straighten,
size: 32,
color: Colors.blue,
),
const SizedBox(height: 8),
Text(
'${_currentRoute!.getTotalDistanceKm()} km',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
Column(
children: [
Icon(
Icons.timer,
size: 32,
color: Colors.orange,
),
const SizedBox(height: 8),
Text(
_currentRoute!.getTotalDurationHM(),
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
Column(
children: [
Icon(
Icons.speed,
size: 32,
color: Colors.green,
),
const SizedBox(height: 8),
Text(
'${(_currentRoute!.totalDistance / _currentRoute!.totalDuration * 3.6).toStringAsFixed(0)} km/h',
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
],
),
const SizedBox(height: 16),
// Route details
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.location_on,
color: Colors.green,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'From: ${_currentRoute!.start.name}',
style: Theme.of(context)
.textTheme
.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.location_on,
color: Colors.red,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'To: ${_currentRoute!.end.name}',
style: Theme.of(context)
.textTheme
.bodyMedium,
),
),
],
),
],
),
),
],
),
),
),
),
),
],
),
);
}
}
View File
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
/// Authentication Service
class AuthService {
// TODO: Implement API connector - configure base URL from env/config
static const String _apiBaseUrl = 'http://localhost:5000';
static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs;
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
/// Login with userCode and password
Future<AuthResponse> login({
required String userCode,
required String password,
}) async {
await _initPrefs();
try {
final response = await http.post(
Uri.parse('$_apiBaseUrl/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'userCode': userCode,
'password': password,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
// Store token
await _prefs.setString(_tokenKey, authResponse.token);
return authResponse;
} else if (response.statusCode == 401) {
throw Exception('Invalid credentials');
} else {
throw Exception('Login failed: ${response.statusCode}');
}
} catch (e) {
throw Exception('Login error: $e');
}
}
/// Get stored token
Future<String?> getToken() async {
await _initPrefs();
return _prefs.getString(_tokenKey);
}
/// Check if user is logged in
Future<bool> isLoggedIn() async {
final token = await getToken();
return token != null;
}
/// Logout
Future<void> logout() async {
await _initPrefs();
await _prefs.remove(_tokenKey);
}
/// Get Authorization header
Future<Map<String, String>> getAuthHeaders() async {
final token = await getToken();
return {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
}
}
@@ -0,0 +1,128 @@
import 'dart:convert';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../models/navigation.dart';
/// Navigation Service using OpenStreetMap
class NavigationService {
// OSRM (OpenStreetMap Routing Machine) - free public service
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/car';
/// Get current device location
Future<NavigationPoint?> getCurrentLocation() async {
try {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
await Geolocator.requestPermission();
}
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
return NavigationPoint(
latitude: position.latitude,
longitude: position.longitude,
name: 'Current Location',
);
} catch (e) {
throw Exception('Failed to get location: $e');
}
}
/// Get route between two points
/// Uses OSRM for open routing without API key
Future<NavigationRoute> getRoute({
required NavigationPoint start,
required NavigationPoint end,
}) async {
try {
// OSRM format: /route/v1/car/lon1,lat1;lon2,lat2
final coordinates =
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}';
final url =
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson';
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 route = NavigationRoute.fromJson(data);
// Set start and end points properly
return NavigationRoute(
start: start,
end: end,
legs: route.legs,
totalDistance: route.totalDistance,
totalDuration: route.totalDuration,
);
} else {
throw Exception('Failed to get route: ${response.statusCode}');
}
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Get multiple routes (alternatives)
Future<List<NavigationRoute>> getMultipleRoutes({
required NavigationPoint start,
required NavigationPoint end,
int alternatives = 2,
}) async {
try {
final coordinates =
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}';
final url =
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&alternatives=$alternatives';
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 routes = (data['routes'] as List)
.map((routeData) {
return NavigationRoute(
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(),
);
})
.toList();
return routes;
} else {
throw Exception('Failed to get routes: ${response.statusCode}');
}
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Calculate straight-line distance between two points
double calculateDistance(NavigationPoint p1, NavigationPoint p2) {
const distance = Distance();
return distance(
LatLng(p1.latitude, p1.longitude),
LatLng(p2.latitude, p2.longitude),
);
}
}
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import 'auth_service.dart';
/// Schedule/Route Service
class ScheduleService {
// TODO: Implement API connector - configure base URL from env/config
static const String _apiBaseUrl = 'http://localhost:5000';
final _authService = AuthService();
/// Get daily schedule for a specific date
Future<DailySchedule> getDailySchedule(DateTime date) async {
try {
final headers = await _authService.getAuthHeaders();
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final response = await http.get(
Uri.parse('$_apiBaseUrl/schedule/daily?date=$dateStr'),
headers: headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return DailySchedule.fromJson(data);
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else {
throw Exception('Failed to load schedule: ${response.statusCode}');
}
} catch (e) {
throw Exception('Schedule error: $e');
}
}
/// Get today's schedule
Future<DailySchedule> getTodaySchedule() async {
return getDailySchedule(DateTime.now());
}
/// Get route details
Future<Route> getRouteDetails(int routeId) async {
try {
final headers = await _authService.getAuthHeaders();
final response = await http.get(
Uri.parse('$_apiBaseUrl/schedule/route/$routeId'),
headers: headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return Route.fromJson(data);
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else {
throw Exception('Failed to load route: ${response.statusCode}');
}
} catch (e) {
throw Exception('Route error: $e');
}
}
/// Update route status
Future<void> updateRouteStatus(int routeId, String status) async {
try {
final headers = await _authService.getAuthHeaders();
final response = await http.patch(
Uri.parse('$_apiBaseUrl/schedule/route/$routeId/status'),
headers: headers,
body: jsonEncode({'status': status}),
);
if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.statusCode}');
}
} catch (e) {
throw Exception('Status update error: $e');
}
}
}
+24
View File
@@ -0,0 +1,24 @@
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 ThemeData lightTheme() {
return ThemeData(
useMaterial3: true,
// TODO: Configure Material 3 theme
);
}
static ThemeData darkTheme() {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
// TODO: Configure Material 3 dark theme
);
}
}
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
/// Custom input field optimized for bus drivers
/// - Large touch targets (40+ height)
/// - Clear visibility
/// - Accessible design
class CustomInputField extends StatefulWidget {
final String label;
final String hint;
final TextEditingController controller;
final bool obscureText;
final TextInputType keyboardType;
final String? Function(String?)? validator;
const CustomInputField({
super.key,
required this.label,
required this.hint,
required this.controller,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.validator,
});
@override
State<CustomInputField> createState() => _CustomInputFieldState();
}
class _CustomInputFieldState extends State<CustomInputField> {
late bool _obscureText;
@override
void initState() {
super.initState();
_obscureText = widget.obscureText;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
TextFormField(
controller: widget.controller,
obscureText: _obscureText,
keyboardType: widget.keyboardType,
validator: widget.validator,
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: Theme.of(context).textTheme.bodyMedium,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16, // Larger vertical padding for touch targets
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
suffixIcon: widget.obscureText
? GestureDetector(
onTap: () {
setState(() => _obscureText = !_obscureText);
},
child: Icon(
_obscureText ? Icons.visibility_off : Icons.visibility,
size: 28, // Larger icon for accessibility
),
)
: null,
),
style: Theme.of(context).textTheme.bodyLarge,
),
],
);
}
}
+222
View File
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart' as schedule;
import '../screens/navigation_screen.dart';
/// Route card widget showing route details
class RouteCard extends StatelessWidget {
final schedule.Route route;
final VoidCallback onTap;
const RouteCard({
super.key,
required this.route,
required this.onTap,
});
Color _getStatusColor(String status) {
switch (status) {
case 'scheduled':
return Colors.blue;
case 'in_progress':
return Colors.orange;
case 'completed':
return Colors.green;
case 'cancelled':
return Colors.red;
default:
return Colors.grey;
}
}
String _getStatusLabel(String status) {
switch (status) {
case 'scheduled':
return 'Planned';
case 'in_progress':
return 'In Progress';
case 'completed':
return 'Completed';
case 'cancelled':
return 'Cancelled';
default:
return status;
}
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final statusColor = _getStatusColor(route.status);
final startTimeStr = _formatTime(route.startTime);
return GestureDetector(
onTap: () {
// Navigate to navigation screen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NavigationScreen(route: route),
),
);
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Line number + Status
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Line number - Large and prominent
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Line ${route.busLine.lineNumber}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
// Status badge
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
border: Border.all(color: statusColor),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getStatusLabel(route.status),
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
// Route info: Start -> End
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'From',
style: Theme.of(context).textTheme.labelSmall,
),
Text(
route.busLine.startStop,
style:
Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
Icon(
Icons.arrow_forward,
color: Colors.grey[400],
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'To',
style: Theme.of(context).textTheme.labelSmall,
),
Text(
route.busLine.endStop,
style:
Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Bus info and time
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Bus type
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bus',
style: Theme.of(context).textTheme.labelSmall,
),
Text(
'${route.busType.name} (${route.busType.seatCapacity} seats)',
style:
Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
route.busType.licensePlate,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
// Start time
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Start Time',
style: Theme.of(context).textTheme.labelSmall,
),
Text(
startTimeStr,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 4),
Text(
'${route.busLine.estimatedDuration} min duration',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
],
),
],
),
),
),
);
}
}