371 lines
12 KiB
Dart
371 lines
12 KiB
Dart
// ================================================================
|
|
// 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<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'],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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<ETAInfo> 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<String, dynamic> 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<List<String>> obtenerColonias() async {
|
|
final url = Uri.parse('$_baseUrl/api/colonias');
|
|
|
|
final response = await http.get(url).timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
final Map<String, dynamic> jsonData = json.decode(response.body);
|
|
// El backend devuelve: { "colonias": ["Zona Centro", "Col. Hidalgo", ...] }
|
|
return List<String>.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<int> 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<String, dynamic> 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<int> 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<String, dynamic> 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<UsuarioInfo> 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<String, dynamic> 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<List<RouteInfo>> obtenerRutas() async {
|
|
final url = Uri.parse('$_baseUrl/api/rutas');
|
|
final response = await http.get(url).timeout(_timeout);
|
|
|
|
if (response.statusCode == 200) {
|
|
final Map<String, dynamic> jsonData = json.decode(response.body);
|
|
return List<Map<String, dynamic>>.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<RouteInfo> 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<String, dynamic> 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<void> 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<void> 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}');
|
|
}
|
|
}
|
|
}
|