Add Docker build workflow and mobile application
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user