// ================================================================ // 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 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 json) { return DireccionInfo(colonia: json['colonia'], direccion: json['direccion']); } } class UsuarioInfo { final int usuarioId; final String nombre; final String email; final List direcciones; UsuarioInfo({ required this.usuarioId, required this.nombre, required this.email, required this.direcciones, }); factory UsuarioInfo.fromJson(Map json) { return UsuarioInfo( usuarioId: json['usuario_id'], nombre: json['nombre'], email: json['email'], direcciones: List>.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 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 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 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 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 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>.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 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 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> 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 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 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> obtenerColonias() async { final response = await http .get(Uri.parse('$_baseUrl/api/colonias')) .timeout(_timeout); if (response.statusCode == 200) { return List.from(json.decode(response.body)['colonias']); } _throwError(response); } Future 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 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 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 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> 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>.from(json.decode(response.body)['rutas']) .map(RouteInfo.fromJson) .toList(); } _throwError(response); } Future 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 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> historialPosiciones(String routeId) async { final response = await http .get(Uri.parse('$_baseUrl/api/rutas/$routeId/posiciones')) .timeout(_timeout); if (response.statusCode == 200) { return List>.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>> resumenRutas() async { final response = await http .get(Uri.parse('$_baseUrl/api/rutas/resumen')) .timeout(_timeout); if (response.statusCode == 200) { return List>.from(json.decode(response.body)['rutas']); } _throwError(response); } /// Estadísticas por colonia: usuarios y cobertura de notificaciones. Future> estadisticasColonias() async { final response = await http .get(Uri.parse('$_baseUrl/api/estadisticas/colonias')) .timeout(_timeout); if (response.statusCode == 200) { return List>.from(json.decode(response.body)) .map(ColoniaEstadistica.fromJson) .toList(); } _throwError(response); } }