484 lines
15 KiB
Dart
484 lines
15 KiB
Dart
// ================================================================
|
|
// lib/services/api_service.dart (v2)
|
|
// Servicio de comunicación con el backend FastAPI
|
|
// ================================================================
|
|
//
|
|
// CAMBIOS v2:
|
|
// - LoginResponse incluye 'nombre' del usuario
|
|
// - ActualizarPassword requiere password_actual + password_nuevo
|
|
// - Nuevos métodos: obtenerDashboard, historialPosiciones,
|
|
// resumenRutas, estadisticasColonias
|
|
// ================================================================
|
|
|
|
import 'dart:convert';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
// ----------------------------------------------------------------
|
|
// MODELOS
|
|
// ----------------------------------------------------------------
|
|
|
|
class ETAInfo {
|
|
final int usuarioId;
|
|
final String colonia;
|
|
final String rutaNombre;
|
|
final String rutaStatus;
|
|
final bool gpsOk;
|
|
final String etaTexto;
|
|
final int etaMinutos;
|
|
final String mensajePreventivo;
|
|
|
|
ETAInfo({
|
|
required this.usuarioId,
|
|
required this.colonia,
|
|
required this.rutaNombre,
|
|
required this.rutaStatus,
|
|
required this.gpsOk,
|
|
required this.etaTexto,
|
|
required this.etaMinutos,
|
|
required this.mensajePreventivo,
|
|
});
|
|
|
|
factory ETAInfo.fromJson(Map<String, dynamic> json) {
|
|
return ETAInfo(
|
|
usuarioId: json['usuario_id'],
|
|
colonia: json['colonia'],
|
|
rutaNombre: json['ruta_nombre'] ?? '',
|
|
rutaStatus: json['ruta_status'] ?? '',
|
|
gpsOk: json['gps_ok'] ?? true,
|
|
etaTexto: json['eta_texto'],
|
|
etaMinutos: json['eta_minutos'],
|
|
mensajePreventivo: json['mensaje_preventivo'],
|
|
);
|
|
}
|
|
}
|
|
|
|
class DireccionInfo {
|
|
final String colonia;
|
|
final String direccion;
|
|
|
|
DireccionInfo({required this.colonia, required this.direccion});
|
|
|
|
factory DireccionInfo.fromJson(Map<String, dynamic> json) {
|
|
return DireccionInfo(colonia: json['colonia'], direccion: json['direccion']);
|
|
}
|
|
}
|
|
|
|
class UsuarioInfo {
|
|
final int usuarioId;
|
|
final String nombre;
|
|
final String email;
|
|
final List<DireccionInfo> direcciones;
|
|
|
|
UsuarioInfo({
|
|
required this.usuarioId,
|
|
required this.nombre,
|
|
required this.email,
|
|
required this.direcciones,
|
|
});
|
|
|
|
factory UsuarioInfo.fromJson(Map<String, dynamic> json) {
|
|
return UsuarioInfo(
|
|
usuarioId: json['usuario_id'],
|
|
nombre: json['nombre'],
|
|
email: json['email'],
|
|
direcciones: List<Map<String, dynamic>>.from(json['direcciones'])
|
|
.map(DireccionInfo.fromJson)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RouteInfo {
|
|
final String routeId;
|
|
final String name;
|
|
final String status;
|
|
final int lastPositionId;
|
|
final String lastTimestamp;
|
|
final bool gpsOk;
|
|
|
|
RouteInfo({
|
|
required this.routeId,
|
|
required this.name,
|
|
required this.status,
|
|
required this.lastPositionId,
|
|
required this.lastTimestamp,
|
|
required this.gpsOk,
|
|
});
|
|
|
|
factory RouteInfo.fromJson(Map<String, dynamic> json) {
|
|
return RouteInfo(
|
|
routeId: json['route_id'],
|
|
name: json['name'],
|
|
status: json['status'],
|
|
lastPositionId: json['last_position_id'],
|
|
lastTimestamp: json['last_timestamp'],
|
|
gpsOk: json['gps_ok'],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Posición GPS individual de la ruta de un camión.
|
|
class PosicionGPS {
|
|
final int positionId;
|
|
final double lat;
|
|
final double lng;
|
|
final int speed;
|
|
final String timestamp;
|
|
final bool esActual; // true = aquí está el camión ahora
|
|
|
|
PosicionGPS({
|
|
required this.positionId,
|
|
required this.lat,
|
|
required this.lng,
|
|
required this.speed,
|
|
required this.timestamp,
|
|
required this.esActual,
|
|
});
|
|
|
|
factory PosicionGPS.fromJson(Map<String, dynamic> json) {
|
|
return PosicionGPS(
|
|
positionId: json['position_id'],
|
|
lat: (json['lat'] as num).toDouble(),
|
|
lng: (json['lng'] as num).toDouble(),
|
|
speed: json['speed'],
|
|
timestamp: json['timestamp'],
|
|
esActual: json['es_actual'] ?? false,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Detalle de una ruta para el dashboard.
|
|
class RutaDetalle {
|
|
final String routeId;
|
|
final String name;
|
|
final String status;
|
|
final int truckId;
|
|
final int posicionActual;
|
|
final int totalPosiciones;
|
|
final double porcentajeCompletado;
|
|
final int etaMinutos;
|
|
final bool gpsOk;
|
|
final int usuariosEnRuta;
|
|
|
|
RutaDetalle({
|
|
required this.routeId,
|
|
required this.name,
|
|
required this.status,
|
|
required this.truckId,
|
|
required this.posicionActual,
|
|
required this.totalPosiciones,
|
|
required this.porcentajeCompletado,
|
|
required this.etaMinutos,
|
|
required this.gpsOk,
|
|
required this.usuariosEnRuta,
|
|
});
|
|
|
|
factory RutaDetalle.fromJson(Map<String, dynamic> json) {
|
|
return RutaDetalle(
|
|
routeId: json['route_id'],
|
|
name: json['name'],
|
|
status: json['status'],
|
|
truckId: json['truck_id'],
|
|
posicionActual: json['posicion_actual'],
|
|
totalPosiciones: json['total_posiciones'],
|
|
porcentajeCompletado: (json['porcentaje_completado'] as num).toDouble(),
|
|
etaMinutos: json['eta_minutos'],
|
|
gpsOk: json['gps_ok'],
|
|
usuariosEnRuta: json['usuarios_en_ruta'],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Respuesta completa del dashboard de operador.
|
|
class DashboardInfo {
|
|
final int totalRutas;
|
|
final int rutasEnProgreso;
|
|
final int rutasCompletadas;
|
|
final int totalUsuarios;
|
|
final int usuariosConToken;
|
|
final double coberturaNotificaciones;
|
|
final List<RutaDetalle> rutas;
|
|
|
|
DashboardInfo({
|
|
required this.totalRutas,
|
|
required this.rutasEnProgreso,
|
|
required this.rutasCompletadas,
|
|
required this.totalUsuarios,
|
|
required this.usuariosConToken,
|
|
required this.coberturaNotificaciones,
|
|
required this.rutas,
|
|
});
|
|
|
|
factory DashboardInfo.fromJson(Map<String, dynamic> json) {
|
|
return DashboardInfo(
|
|
totalRutas: json['total_rutas'],
|
|
rutasEnProgreso: json['rutas_en_progreso'],
|
|
rutasCompletadas: json['rutas_completadas'],
|
|
totalUsuarios: json['total_usuarios'],
|
|
usuariosConToken: json['usuarios_con_token'],
|
|
coberturaNotificaciones: (json['cobertura_notificaciones'] as num).toDouble(),
|
|
rutas: List<Map<String, dynamic>>.from(json['rutas'])
|
|
.map(RutaDetalle.fromJson)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Estadísticas de una colonia.
|
|
class ColoniaEstadistica {
|
|
final String colonia;
|
|
final String routeId;
|
|
final String rutaNombre;
|
|
final String horario;
|
|
final int totalUsuarios;
|
|
final int usuariosConNotificaciones;
|
|
|
|
ColoniaEstadistica({
|
|
required this.colonia,
|
|
required this.routeId,
|
|
required this.rutaNombre,
|
|
required this.horario,
|
|
required this.totalUsuarios,
|
|
required this.usuariosConNotificaciones,
|
|
});
|
|
|
|
factory ColoniaEstadistica.fromJson(Map<String, dynamic> json) {
|
|
return ColoniaEstadistica(
|
|
colonia: json['colonia'],
|
|
routeId: json['route_id'],
|
|
rutaNombre: json['ruta_nombre'],
|
|
horario: json['horario'],
|
|
totalUsuarios: json['total_usuarios'],
|
|
usuariosConNotificaciones: json['usuarios_con_notificaciones'],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// CLASE PRINCIPAL: ApiService
|
|
// ----------------------------------------------------------------
|
|
class ApiService {
|
|
// ============================================================
|
|
// BASE URL — Cambia solo esta línea para apuntar a otro entorno
|
|
// Android emulator local: http://10.0.2.2:8000
|
|
// Dispositivo físico (red local): http://192.168.X.X:8000
|
|
// ============================================================
|
|
static const String _baseUrl = 'http://192.168.198.224:8000';
|
|
static const Duration _timeout = Duration(seconds: 10);
|
|
|
|
// ----------------------------------------------------------------
|
|
// HELPER PRIVADO: maneja errores HTTP de forma consistente
|
|
// ----------------------------------------------------------------
|
|
Never _throwError(http.Response response) {
|
|
Map<String, dynamic> body = {};
|
|
try {
|
|
body = json.decode(response.body);
|
|
} catch (_) {}
|
|
final detail = body['detail'] ?? response.body;
|
|
throw Exception(detail);
|
|
}
|
|
|
|
// ================================================================
|
|
// AUTENTICACIÓN
|
|
// ================================================================
|
|
|
|
/// Login con email y contraseña. Retorna [usuarioId, nombre].
|
|
Future<Map<String, dynamic>> loginConCorreo(String email, String password) async {
|
|
final response = await http.post(
|
|
Uri.parse('$_baseUrl/api/usuarios/login'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({'email': email.trim().toLowerCase(), 'password': password}),
|
|
).timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body);
|
|
return {'usuario_id': data['usuario_id'], 'nombre': data['nombre']};
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
Future<int> registrarUsuario(
|
|
String nombre,
|
|
String email,
|
|
String password,
|
|
String direccion,
|
|
String colonia,
|
|
) async {
|
|
final response = await http.post(
|
|
Uri.parse('$_baseUrl/api/usuarios/register'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({
|
|
'nombre': nombre.trim(),
|
|
'email': email.trim().toLowerCase(),
|
|
'password': password,
|
|
'colonia': colonia,
|
|
'direccion': direccion.trim(),
|
|
}),
|
|
).timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return json.decode(response.body)['usuario_id'];
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
// ================================================================
|
|
// USUARIOS
|
|
// ================================================================
|
|
|
|
Future<ETAInfo> obtenerETA(int usuarioId) async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/eta/$usuarioId'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return ETAInfo.fromJson(json.decode(response.body));
|
|
} else if (response.statusCode == 404) {
|
|
throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?');
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
Future<List<String>> obtenerColonias() async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/colonias'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return List<String>.from(json.decode(response.body)['colonias']);
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
Future<UsuarioInfo> obtenerUsuario(int usuarioId) async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/usuarios/$usuarioId'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return UsuarioInfo.fromJson(json.decode(response.body));
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
Future<void> agregarDireccion(int usuarioId, String colonia, String direccion) async {
|
|
final response = await http.post(
|
|
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/direcciones'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({'colonia': colonia, 'direccion': direccion.trim()}),
|
|
).timeout(_timeout);
|
|
|
|
if (response.statusCode != 200) _throwError(response);
|
|
}
|
|
|
|
/// Actualiza contraseña. Requiere la contraseña actual como confirmación.
|
|
Future<void> actualizarPassword(int usuarioId, String passwordActual, String passwordNuevo) async {
|
|
final response = await http.put(
|
|
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/password'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({
|
|
'password_actual': passwordActual,
|
|
'password_nuevo': passwordNuevo,
|
|
}),
|
|
).timeout(_timeout);
|
|
|
|
if (response.statusCode != 200) _throwError(response);
|
|
}
|
|
|
|
Future<void> registrarFcmToken(int usuarioId, String fcmToken) async {
|
|
final response = await http.put(
|
|
Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: json.encode({'fcm_token': fcmToken}),
|
|
).timeout(_timeout);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception('Error registrando FCM token: ${response.statusCode}');
|
|
}
|
|
}
|
|
|
|
// ================================================================
|
|
// RUTAS
|
|
// ================================================================
|
|
|
|
Future<List<RouteInfo>> obtenerRutas(int usuarioId) async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/rutas?usuario_id=$usuarioId'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return List<Map<String, dynamic>>.from(json.decode(response.body)['rutas'])
|
|
.map(RouteInfo.fromJson)
|
|
.toList();
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
Future<RouteInfo> avanzarRuta(String routeId, int usuarioId) async {
|
|
final response = await http
|
|
.post(Uri.parse('$_baseUrl/api/rutas/$routeId/avanzar?usuario_id=$usuarioId'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return RouteInfo.fromJson(json.decode(response.body));
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
// ================================================================
|
|
// VISUALIZACIÓN — NUEVOS EN v2
|
|
// ================================================================
|
|
|
|
/// Dashboard global: estado de todas las rutas + métricas de usuarios.
|
|
Future<DashboardInfo> obtenerDashboard() async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/dashboard'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return DashboardInfo.fromJson(json.decode(response.body));
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
/// Historial de posiciones GPS de una ruta, con la posición actual marcada.
|
|
Future<List<PosicionGPS>> historialPosiciones(String routeId) async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/rutas/$routeId/posiciones'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return List<Map<String, dynamic>>.from(json.decode(response.body))
|
|
.map(PosicionGPS.fromJson)
|
|
.toList();
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
/// Vista rápida y ligera de todas las rutas. Ideal para polling frecuente.
|
|
Future<List<Map<String, dynamic>>> resumenRutas() async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/rutas/resumen'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return List<Map<String, dynamic>>.from(json.decode(response.body)['rutas']);
|
|
}
|
|
_throwError(response);
|
|
}
|
|
|
|
/// Estadísticas por colonia: usuarios y cobertura de notificaciones.
|
|
Future<List<ColoniaEstadistica>> estadisticasColonias() async {
|
|
final response = await http
|
|
.get(Uri.parse('$_baseUrl/api/estadisticas/colonias'))
|
|
.timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
return List<Map<String, dynamic>>.from(json.decode(response.body))
|
|
.map(ColoniaEstadistica.fromJson)
|
|
.toList();
|
|
}
|
|
_throwError(response);
|
|
}
|
|
}
|