// ================================================================ // lib/services/api_service.dart // Servicio de comunicación con el backend FastAPI // ================================================================ // // PATRÓN: Service class singleton. // Una sola instancia maneja todas las llamadas HTTP de la app. // // ATAJO DE HACKATHON: // Usamos la librería 'http' sin abstracciones complejas. // En producción: usar Dio con interceptors para auth headers, // retry automático y mejor manejo de errores. // // CÓMO CONECTAR: // En cada Screen que necesite datos: // final service = ApiService(); // final eta = await service.obtenerETA(usuarioId); // ================================================================ import 'dart:convert'; import 'package:http/http.dart' as http; // ---------------------------------------------------------------- // MODELO: ETAInfo // Representa la respuesta del endpoint GET /api/eta/{usuario_id} // Mapea exactamente los campos que devuelve el backend (main.py) // ---------------------------------------------------------------- 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 constructor: convierte el JSON del backend a objeto Dart. // Los keys del JSON deben coincidir con los fields del ETAResponse // de Pydantic en main.py (usa snake_case, igual que FastAPI). 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'], ); } } // ---------------------------------------------------------------- // CLASE PRINCIPAL: ApiService // ---------------------------------------------------------------- class ApiService { // ============================================================ // BASE URL DEL BACKEND // // DESARROLLO LOCAL: // - Android Emulator: usa 10.0.2.2 (mapea al localhost del PC) // - iOS Simulator: usa 127.0.0.1 // - Dispositivo físico: IP real de tu máquina en la red local // (ej: http://192.168.1.100:8000) // // ATAJO: Cambia solo esta constante para apuntar a staging/prod. // ============================================================ static const String _baseUrl = 'http://192.168.192.96:8000'; // Timeout razonable para demo. Si el backend es lento, sube a 15s. static const Duration _timeout = Duration(seconds: 10); // ---------------------------------------------------------------- // MÉTODO: obtenerETA // // Llama a GET /api/eta/{usuario_id} y retorna un ETAInfo. // Lanza una Exception si hay error de red o el servidor responde // con error (4xx, 5xx). La UI debe manejar el try/catch. // ---------------------------------------------------------------- Future obtenerETA(int usuarioId) async { final url = Uri.parse('$_baseUrl/api/eta/$usuarioId'); try { // Llamada HTTP GET con timeout para no bloquear la UI para siempre final response = await http.get(url).timeout(_timeout); if (response.statusCode == 200) { // Decodifica el body JSON (viene como String, lo convertimos a Map) final Map jsonData = json.decode(response.body); return ETAInfo.fromJson(jsonData); } else if (response.statusCode == 404) { // El usuario no existe en la DB — pide que corran el seed throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?'); } else { // Error genérico del servidor throw Exception('Error del servidor: ${response.statusCode} - ${response.body}'); } } on Exception { // Re-lanzamos para que la UI lo maneje con un mensaje amigable rethrow; } } // ---------------------------------------------------------------- // MÉTODO: obtenerColonias // // Llama a GET /api/colonias para poblar el Dropdown del LoginScreen. // Retorna una lista de strings con los nombres de las colonias. // ---------------------------------------------------------------- Future> obtenerColonias() async { final url = Uri.parse('$_baseUrl/api/colonias'); final response = await http.get(url).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); // El backend devuelve: { "colonias": ["Zona Centro", "Col. Hidalgo", ...] } return List.from(jsonData['colonias']); } else { throw Exception('No se pudieron cargar las colonias.'); } } // ---------------------------------------------------------------- // MÉTODO: loginConCorreo // // Llama a POST /api/usuarios/login con email y obtiene el usuario_id // ---------------------------------------------------------------- Future loginConCorreo(String email) async { final url = Uri.parse('$_baseUrl/api/usuarios/login'); final response = await http.post( url, headers: {'Content-Type': 'application/json'}, body: json.encode({'email': email.trim().toLowerCase()}), ).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); return jsonData['usuario_id']; } else { throw Exception('Error al iniciar sesión: ${response.body}'); } } // ---------------------------------------------------------------- // MÉTODO: registrarUsuario // // Llama a POST /api/usuarios/register y crea el usuario con su primera dirección. // ---------------------------------------------------------------- Future registrarUsuario( String nombre, String email, String direccion, String colonia, ) async { final url = Uri.parse('$_baseUrl/api/usuarios/register'); final response = await http.post( url, headers: {'Content-Type': 'application/json'}, body: json.encode({ 'nombre': nombre.trim(), 'email': email.trim().toLowerCase(), 'colonia': colonia, 'direccion': direccion.trim(), }), ).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); return jsonData['usuario_id']; } else { throw Exception('Error al registrar usuario: ${response.body}'); } } // ---------------------------------------------------------------- // MÉTODO: obtenerUsuario // // Llama a GET /api/usuarios/{usuario_id} y retorna los datos de perfil. // ---------------------------------------------------------------- Future obtenerUsuario(int usuarioId) async { final url = Uri.parse('$_baseUrl/api/usuarios/$usuarioId'); final response = await http.get(url).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); return UsuarioInfo.fromJson(jsonData); } else { throw Exception('Error al obtener usuario: ${response.body}'); } } // ---------------------------------------------------------------- // MÉTODO: obtenerRutas // // Llama a GET /api/rutas para listar el estado actual de cada camión. // ---------------------------------------------------------------- Future> obtenerRutas() async { final url = Uri.parse('$_baseUrl/api/rutas'); final response = await http.get(url).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); return List>.from(jsonData['rutas']) .map(RouteInfo.fromJson) .toList(); } throw Exception('Error al obtener rutas: ${response.body}'); } // ---------------------------------------------------------------- // MÉTODO: avanzarRuta // // Llama a POST /api/rutas/{route_id}/avanzar para simular el avance del camión. // ---------------------------------------------------------------- Future avanzarRuta(String routeId) async { final url = Uri.parse('$_baseUrl/api/rutas/$routeId/avanzar'); final response = await http.post(url).timeout(_timeout); if (response.statusCode == 200) { final Map jsonData = json.decode(response.body); return RouteInfo.fromJson(jsonData); } throw Exception('Error al avanzar la ruta: ${response.body}'); } // ---------------------------------------------------------------- // MÉTODO: agregarDireccion // // Llama a POST /api/usuarios/{usuario_id}/direcciones para guardar // una nueva dirección asociada al usuario. // ---------------------------------------------------------------- Future agregarDireccion( int usuarioId, String colonia, String direccion, ) async { final url = Uri.parse('$_baseUrl/api/usuarios/$usuarioId/direcciones'); final response = await http.post( url, headers: {'Content-Type': 'application/json'}, body: json.encode({ 'colonia': colonia, 'direccion': direccion.trim(), }), ).timeout(_timeout); if (response.statusCode != 200) { throw Exception('Error al guardar la dirección: ${response.body}'); } } // ---------------------------------------------------------------- // MÉTODO: registrarFcmToken // // Envía el FCM token del dispositivo al backend para que pueda // enviar notificaciones push personalizadas. // // CUÁNDO LLAMARLO: // - En HomeScreen al iniciar, después de obtener el token de // FirebaseMessaging.instance.getToken() // ---------------------------------------------------------------- Future registrarFcmToken(int usuarioId, String fcmToken) async { final url = Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token'); final response = await http.put( url, headers: {'Content-Type': 'application/json'}, body: json.encode({'fcm_token': fcmToken}), ).timeout(_timeout); if (response.statusCode != 200) { // No es crítico que falle en el hackathon, solo logueamos throw Exception('Error registrando FCM token: ${response.statusCode}'); } } }