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
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,
),
),
],
),
],
),
),
],
),
),
),
),
),
],
),
);
}
}