Update readme's, branches and actions
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m8s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m8s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'auth_service.dart';
|
||||
|
||||
/// Global navigator and messenger keys so services can navigate/logout
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
final GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Handle expired authentication: clear stored token and navigate to login.
|
||||
Future<void> handleAuthExpired({String? message}) async {
|
||||
try {
|
||||
await AuthService().logout();
|
||||
} catch (_) {}
|
||||
|
||||
// Show a message if possible
|
||||
try {
|
||||
final messenger = messengerKey.currentState;
|
||||
if (messenger != null && message != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Navigate to login, removing all previous routes
|
||||
try {
|
||||
final nav = navigatorKey.currentState;
|
||||
if (nav != null) {
|
||||
nav.pushNamedAndRemoveUntil('/login', (r) => false);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/user.dart';
|
||||
import 'error_log_service.dart';
|
||||
|
||||
/// Authentication Service
|
||||
class AuthService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
static const String _tokenKey = 'auth_token';
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initPrefs() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// Login with userCode and password
|
||||
Future<AuthResponse> login({
|
||||
required int 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 body = response.body;
|
||||
try {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
final authResponse = AuthResponse.fromJson(data);
|
||||
|
||||
// Store token
|
||||
await _prefs.setString(_tokenKey, authResponse.token);
|
||||
|
||||
return authResponse;
|
||||
} catch (e) {
|
||||
await _reportError('AuthService.login.parse', e);
|
||||
// Surface the response body to help debug backend field names / formats
|
||||
throw Exception('Login parse error: $e -- response body: $body');
|
||||
}
|
||||
} else if (response.statusCode == 401) {
|
||||
throw Exception('Invalid credentials');
|
||||
} else {
|
||||
throw Exception('Login failed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('AuthService.login', 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,26 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class ErrorLogService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
|
||||
static Future<void> report({
|
||||
required String service,
|
||||
required String partOfService,
|
||||
required String error,
|
||||
}) async {
|
||||
try {
|
||||
await http.post(
|
||||
Uri.parse('$_apiBaseUrl/error_log'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'service': service,
|
||||
'partOfService': partOfService,
|
||||
'error': error,
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
// Intentionally ignore logging failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Custom exception for authentication errors
|
||||
import 'app_navigator.dart';
|
||||
|
||||
class UnauthorizedException implements Exception {
|
||||
final String message;
|
||||
|
||||
UnauthorizedException(this.message) {
|
||||
// Trigger centralized logout/navigation asynchronously
|
||||
try {
|
||||
handleAuthExpired(message: message);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import 'dart:convert';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'error_log_service.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/driving';
|
||||
|
||||
/// Get current device location
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
await _reportError('NavigationService.getCurrentLocation', 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 {
|
||||
bool validCoord(double lat, double lon) {
|
||||
return lat.isFinite && lon.isFinite && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
if (!validCoord(start.latitude, start.longitude) || !validCoord(end.latitude, end.longitude)) {
|
||||
throw Exception('Invalid coordinates for routing: start=(${start.latitude},${start.longitude}) end=(${end.latitude},${end.longitude})');
|
||||
}
|
||||
// OSRM format: /route/v1/driving/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 == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
} else if (response.statusCode == 400) {
|
||||
// Bad request: OSRM couldn't parse coordinates. Fall back to a simple straight-line route.
|
||||
try {
|
||||
final distanceMeters = calculateDistance(start, end);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt(); // assume ~40 km/h -> 11.11 m/s
|
||||
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct');
|
||||
return NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}');
|
||||
}
|
||||
} else {
|
||||
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getRoute', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a route that passes through the provided stops in the given order.
|
||||
Future<NavigationRoute> getRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 400) {
|
||||
final distanceMeters = _sumDistancesBetween(stops);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(
|
||||
points: stops,
|
||||
distance: distanceMeters,
|
||||
duration: durationSec,
|
||||
summary: 'Direct fallback',
|
||||
);
|
||||
return NavigationRoute(
|
||||
start: stops.first,
|
||||
end: stops.last,
|
||||
legs: [leg],
|
||||
totalDistance: distanceMeters,
|
||||
totalDuration: durationSec,
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
throw Exception('No route found in response');
|
||||
}
|
||||
|
||||
final routeData = routes.first as Map<String, dynamic>;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: stops.first,
|
||||
end: stops.last,
|
||||
);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getRouteThroughStops', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an optimized trip through multiple stops.
|
||||
/// Keeps the first and last stops fixed and lets OSRM order the intermediates.
|
||||
Future<OptimizedRouteResult> getOptimizedRouteThroughStops({
|
||||
required List<NavigationPoint> stops,
|
||||
}) async {
|
||||
try {
|
||||
if (stops.length < 2) {
|
||||
throw Exception('At least two stops are required');
|
||||
}
|
||||
|
||||
final coordinates = stops
|
||||
.map((stop) => '${stop.longitude},${stop.latitude}')
|
||||
.join(';');
|
||||
final url =
|
||||
'https://router.project-osrm.org/trip/v1/driving/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false';
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 400) {
|
||||
// fallback: return simple ordered stops route
|
||||
final distanceMeters = _sumDistancesBetween(stops);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(points: stops, distance: distanceMeters, duration: durationSec, summary: 'Fallback optimized trip');
|
||||
final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
|
||||
return OptimizedRouteResult(route: route, orderedStops: stops);
|
||||
}
|
||||
throw Exception('Failed to get optimized route: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||
if (data == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final trips = data['trips'] as List?;
|
||||
if (trips == null || trips.isEmpty) {
|
||||
throw Exception('No optimized trip found');
|
||||
}
|
||||
|
||||
final trip = trips.first as Map<String, dynamic>;
|
||||
final geometry = trip['geometry'] as Map<String, dynamic>?;
|
||||
final coordinatesData = geometry?['coordinates'] as List? ?? [];
|
||||
final points = coordinatesData
|
||||
.map((coord) {
|
||||
if (coord is List && coord.length >= 2) {
|
||||
return NavigationPoint(
|
||||
latitude: (coord[1] as num).toDouble(),
|
||||
longitude: (coord[0] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.whereType<NavigationPoint>()
|
||||
.toList();
|
||||
|
||||
final waypointItems = (data['waypoints'] as List?) ?? [];
|
||||
final orderedPairs = <MapEntry<int, NavigationPoint>>[];
|
||||
|
||||
for (var i = 0; i < waypointItems.length && i < stops.length; i++) {
|
||||
final waypoint = waypointItems[i];
|
||||
if (waypoint is! Map<String, dynamic>) continue;
|
||||
final index = waypoint['waypoint_index'] as num?;
|
||||
if (index == null) continue;
|
||||
orderedPairs.add(MapEntry(index.toInt(), stops[i]));
|
||||
}
|
||||
|
||||
orderedPairs.sort((a, b) => a.key.compareTo(b.key));
|
||||
final orderedStops = orderedPairs.isNotEmpty
|
||||
? orderedPairs.map((entry) => entry.value).toList()
|
||||
: stops;
|
||||
|
||||
final route = NavigationRoute(
|
||||
start: orderedStops.first,
|
||||
end: orderedStops.last,
|
||||
legs: [
|
||||
RouteLeg.fromPoints(
|
||||
points: points,
|
||||
distance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
duration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
summary: 'Optimized trip',
|
||||
),
|
||||
],
|
||||
totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
|
||||
totalDuration: (trip['duration'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
return OptimizedRouteResult(
|
||||
route: route,
|
||||
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
|
||||
);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getOptimizedRouteThroughStops', 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 == null) {
|
||||
throw Exception('Empty response from routing service');
|
||||
}
|
||||
|
||||
if (data['code'] != 'Ok') {
|
||||
throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}');
|
||||
}
|
||||
|
||||
final routesData = data['routes'] as List?;
|
||||
if (routesData == null || routesData.isEmpty) {
|
||||
throw Exception('No routes found in response');
|
||||
}
|
||||
|
||||
final routes = routesData
|
||||
.map((routeData) {
|
||||
if (routeData is! Map<String, dynamic>) return null;
|
||||
return NavigationRoute.fromOsrmRouteData(
|
||||
routeData,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
})
|
||||
.whereType<NavigationRoute>()
|
||||
.toList();
|
||||
|
||||
return routes;
|
||||
} else if (response.statusCode == 400) {
|
||||
// fallback: single straight-line route
|
||||
final distanceMeters = calculateDistance(start, end);
|
||||
final durationSec = (distanceMeters / 11.111111).toInt();
|
||||
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct');
|
||||
return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)];
|
||||
} else {
|
||||
throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.getMultipleRoutes', e);
|
||||
throw Exception('Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
double _sumDistancesBetween(List<NavigationPoint> points) {
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
total += calculateDistance(points[i], points[i + 1]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 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),
|
||||
);
|
||||
}
|
||||
|
||||
/// Geocode a place name to coordinates using Nominatim (OpenStreetMap)
|
||||
Future<NavigationPoint> geocodePlace(String name) async {
|
||||
try {
|
||||
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
|
||||
'q': name,
|
||||
'format': 'json',
|
||||
'limit': '1',
|
||||
});
|
||||
|
||||
final response = await http.get(uri, headers: {
|
||||
'User-Agent': 'bus-terminal-app',
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Geocoding failed: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final list = jsonDecode(response.body) as List<dynamic>?;
|
||||
if (list == null || list.isEmpty) {
|
||||
throw Exception('No geocoding result for "$name"');
|
||||
}
|
||||
|
||||
final item = list[0] as Map<String, dynamic>;
|
||||
final lat = double.parse(item['lat'] as String);
|
||||
final lon = double.parse(item['lon'] as String);
|
||||
final display = item['display_name'] as String? ?? name;
|
||||
|
||||
return NavigationPoint(latitude: lat, longitude: lon, name: display);
|
||||
} catch (e) {
|
||||
await _reportError('NavigationService.geocodePlace', e);
|
||||
throw Exception('Geocode error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/schedule.dart';
|
||||
import '../models/navigation.dart';
|
||||
import 'auth_service.dart';
|
||||
import 'error_log_service.dart';
|
||||
import 'exceptions.dart';
|
||||
|
||||
/// Schedule/Route Service
|
||||
class ScheduleService {
|
||||
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
|
||||
final _authService = AuthService();
|
||||
|
||||
Future<void> _reportError(String partOfService, Object error) {
|
||||
return ErrorLogService.report(
|
||||
service: 'bus_terminal',
|
||||
partOfService: partOfService,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get public schedule overview for a specific date.
|
||||
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final queryParameters = <String, String>{'date': dateStr};
|
||||
if (lineId != null) {
|
||||
queryParameters['lineId'] = lineId.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(queryParameters: queryParameters);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['schedules'] as List? ?? [])
|
||||
.map((schedule) => schedule as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load schedules: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getSchedules', e);
|
||||
throw Exception('Schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all recurring schedule templates, independent of a specific date.
|
||||
Future<List<ServiceSchedule>> getRecurringSchedules({int? lineId}) async {
|
||||
try {
|
||||
final queryParameters = <String, String>{};
|
||||
if (lineId != null) {
|
||||
queryParameters['lineId'] = lineId.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/recurring').replace(queryParameters: queryParameters);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['schedules'] as List? ?? [])
|
||||
.map((schedule) => schedule as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load recurring schedules: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getRecurringSchedules', e);
|
||||
throw Exception('Recurring schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stop list for a specific schedule.
|
||||
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$_apiBaseUrl/schedules/$scheduleId/stops'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (data['stops'] as List? ?? [])
|
||||
.map((stop) => stop as Map<String, dynamic>)
|
||||
.map(ScheduleStop.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
throw Exception('Failed to load schedule stops: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getScheduleStops', e);
|
||||
throw Exception('Schedule stops error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get daily schedule for a specific date (includes recurring schedules)
|
||||
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// Call /schedules/overview which includes both one-time and recurring schedules for the date
|
||||
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(
|
||||
queryParameters: {'date': dateStr}
|
||||
);
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final schedules = (data['schedules'] as List? ?? [])
|
||||
.map((s) => s as Map<String, dynamic>)
|
||||
.map(ServiceSchedule.fromJson)
|
||||
.toList();
|
||||
|
||||
// Convert ServiceSchedule to Route objects for display
|
||||
final routes = schedules.map((schedule) {
|
||||
return Route(
|
||||
id: schedule.id,
|
||||
busLine: BusLine(
|
||||
id: schedule.busLineId,
|
||||
lineNumber: schedule.lineNumber,
|
||||
startStop: '',
|
||||
endStop: '',
|
||||
estimatedDuration: 60,
|
||||
description: '',
|
||||
),
|
||||
busType: BusType(
|
||||
id: schedule.busId,
|
||||
name: schedule.busName,
|
||||
seatCapacity: 0,
|
||||
licensePlate: '',
|
||||
),
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
status: schedule.status,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return DailySchedule(
|
||||
routes: routes,
|
||||
date: date,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to load schedule: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getDailySchedule', e);
|
||||
throw Exception('Schedule error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get today's schedule
|
||||
Future<DailySchedule> getTodaySchedule() async {
|
||||
return getDailySchedule(DateTime.now());
|
||||
}
|
||||
|
||||
/// Get per-date progress for a schedule occurrence (public endpoint)
|
||||
Future<Map<String, dynamic>> getScheduleProgress(int scheduleId, DateTime date) async {
|
||||
try {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final uri = Uri.parse('$_apiBaseUrl/public/schedules/$scheduleId/progress').replace(queryParameters: {'date': dateStr});
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
throw Exception('Failed to load progress: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
await _reportError('ScheduleService.getScheduleProgress', e);
|
||||
throw Exception('Schedule progress error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get route details
|
||||
Future<Route> getRouteDetails(int routeId) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_apiBaseUrl/routes/$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 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else {
|
||||
final body = response.body;
|
||||
throw Exception('Failed to load route: ${response.statusCode} -- response body: $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.getRouteDetails', e);
|
||||
throw Exception('Route error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update route status
|
||||
Future<void> updateRouteStatus(int routeId, String status) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
// Transit API updates status at /routes/:routeId/status
|
||||
final response = await http.patch(
|
||||
Uri.parse('$_apiBaseUrl/routes/$routeId/status'),
|
||||
headers: headers,
|
||||
body: jsonEncode({'status': status}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.updateRouteStatus', e);
|
||||
throw Exception('Status update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update schedule status for driver-facing schedule workflows.
|
||||
Future<void> updateScheduleStatus(int scheduleId, String status, {DateTime? date}) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final body = <String, dynamic>{'status': status};
|
||||
if (date != null) {
|
||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
body['date'] = dateStr;
|
||||
}
|
||||
|
||||
final response = await http.patch(
|
||||
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'),
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update schedule status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.updateScheduleStatus', e);
|
||||
throw Exception('Schedule status update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stops for a bus line
|
||||
Future<List<NavigationPoint>> getStops(int busLineId) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final raw = data['stops'] as List? ?? [];
|
||||
final stops = raw.map((s) => s as Map<String, dynamic>).map((s) {
|
||||
final lat = (s['latitude'] ?? s['lat']) as num?;
|
||||
final lon = (s['longitude'] ?? s['lon']) as num?;
|
||||
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
|
||||
if (lat == null || lon == null) return null;
|
||||
return NavigationPoint(
|
||||
latitude: lat.toDouble(),
|
||||
longitude: lon.toDouble(),
|
||||
name: name,
|
||||
);
|
||||
}).whereType<NavigationPoint>().toList();
|
||||
|
||||
return stops;
|
||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
} else {
|
||||
final body = response.body;
|
||||
throw Exception('Failed to load stops: ${response.statusCode} -- response body: $body');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.getStops', e);
|
||||
throw Exception('Get stops error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a schedule stop as passed by the driver.
|
||||
Future<void> markScheduleStopPassed({
|
||||
required int scheduleId,
|
||||
required int stopOrder,
|
||||
DateTime? actualPassedAt,
|
||||
}) async {
|
||||
try {
|
||||
final headers = await _authService.getAuthHeaders();
|
||||
|
||||
final body = <String, dynamic>{};
|
||||
if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String();
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'),
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await _authService.logout();
|
||||
throw UnauthorizedException('Session expired - Please login again');
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to mark stop as passed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is UnauthorizedException) rethrow;
|
||||
await _reportError('ScheduleService.markScheduleStopPassed', e);
|
||||
throw Exception('Mark stop passed error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user