From 88b580229c63f17b87742aa1f7b9821533484066 Mon Sep 17 00:00:00 2001 From: hack_23030943_f11325 Date: Sat, 23 May 2026 01:29:27 -0600 Subject: [PATCH] Funcionalidades implementadas: --- aplicacion_hack/lib/main.dart | 3 + aplicacion_hack/lib/screens/home_screen.dart | 1279 ++++++++------- aplicacion_hack/lib/screens/info_screen.dart | 689 ++++++++ aplicacion_hack/lib/screens/login_screen.dart | 554 +++---- .../lib/screens/route_list_screen.dart | 48 +- aplicacion_hack/lib/services/api_service.dart | 501 +++--- backend/hackathon.db | Bin 24576 -> 24576 bytes backend/main.py | 1449 +++++++++++------ 8 files changed, 2892 insertions(+), 1631 deletions(-) create mode 100644 aplicacion_hack/lib/screens/info_screen.dart diff --git a/aplicacion_hack/lib/main.dart b/aplicacion_hack/lib/main.dart index 6f85fc6..b8d2cf3 100644 --- a/aplicacion_hack/lib/main.dart +++ b/aplicacion_hack/lib/main.dart @@ -19,6 +19,8 @@ import 'screens/login_screen.dart'; import 'screens/home_screen.dart'; import 'screens/route_list_screen.dart'; import 'firebase_options.dart'; // Opcional si usas FlutterFire CLI para generar opciones +import 'screens/info_screen.dart'; + // ---------------------------------------------------------------- // HANDLER DE MENSAJES EN BACKGROUND // @@ -97,6 +99,7 @@ class ResiduosApp extends StatelessWidget { '/': (context) => const LoginScreen(), '/home': (context) => const HomeScreen(), '/routes': (context) => const RouteListScreen(), + '/info': (context) => const InfoScreen(), }, ); } diff --git a/aplicacion_hack/lib/screens/home_screen.dart b/aplicacion_hack/lib/screens/home_screen.dart index 58beae7..bc25207 100644 --- a/aplicacion_hack/lib/screens/home_screen.dart +++ b/aplicacion_hack/lib/screens/home_screen.dart @@ -1,29 +1,17 @@ // ================================================================ -// lib/screens/home_screen.dart -// Pantalla Principal — Visualización de ETA y Mensajería Preventiva +// lib/screens/home_screen.dart (v2) // ================================================================ // -// PROPÓSITO: -// Mostrar de forma CLARA y VISUAL el estado del camión de -// recolección y el mensaje preventivo correspondiente. -// -// FLUJO: -// 1. Recibe usuario_id desde LoginScreen (Navigator arguments) -// 2. Llama a ApiService.obtenerETA() en initState -// 3. Muestra el mensaje preventivo con diseño de alto impacto -// 4. Se refresca cada 60 segundos para simular actualización real -// 5. Registra el FCM token si Firebase está disponible -// -// DECISIÓN DE DISEÑO: -// El texto del mensaje preventivo es ENORME y ocupa el centro -// de la pantalla. En una app real de alertas críticas, la -// claridad visual es más importante que la estética. +// CAMBIOS v2: +// - Muestra el nombre del usuario (guardado en prefs) +// - Botón de cerrar sesión con confirmación (dialog) +// - Botón de cambiar contraseña en el menú +// - Datos del usuario cargados desde prefs (más rápido, sin esperar API) // ================================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -// Descomenta cuando Firebase esté configurado: import 'package:firebase_messaging/firebase_messaging.dart'; import '../services/api_service.dart'; @@ -35,10 +23,8 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State with TickerProviderStateMixin { - // ---------------------------------------------------------------- - // ESTADO LOCAL - // ---------------------------------------------------------------- int? _usuarioId; + String _nombreUsuario = ''; ETAInfo? _etaInfo; List _direcciones = []; List _colonias = []; @@ -47,14 +33,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { String? _error; String? _errorColonias; - // Timer para auto-refresh cada 60 segundos Timer? _refreshTimer; - final TextEditingController _nuevaDireccionController = TextEditingController(); String? _nuevaColoniaSeleccionada; - // Controlador de animación para el pulso del círculo de ETA late AnimationController _pulseController; late Animation _pulseAnimation; @@ -67,41 +50,24 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); - - // Configurar animación de pulso (escala entre 1.0 y 1.05) - // Da vida a la UI y atrae atención al ETA — importante para demos - _pulseController = AnimationController( - vsync: this, - duration: const Duration(seconds: 2), - )..repeat(reverse: true); - + _pulseController = + AnimationController(vsync: this, duration: const Duration(seconds: 2)) + ..repeat(reverse: true); _pulseAnimation = Tween(begin: 1.0, end: 1.05).animate( CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), ); - - // didChangeDependencies se llama después de initState y tiene - // acceso al context (necesario para Navigator.arguments). - // Por eso la carga de datos inicial va en didChangeDependencies. } @override void didChangeDependencies() { super.didChangeDependencies(); - - // Obtener el usuario_id pasado desde LoginScreen - // Solo lo hacemos una vez (cuando _usuarioId aún es null) if (_usuarioId == null) { final args = ModalRoute.of(context)?.settings.arguments; if (args is int) { _usuarioId = args; - _cargarETA(); - _iniciarAutoRefresh(); - _registrarFCMToken(); // Activar cuando Firebase esté listo - _cargarUsuario(); - _cargarColonias(); + _inicializar(); } else { - // Fallback: leer de shared_preferences si no viene por argumento - _cargarUsuarioDeStorage(); + _cargarDesdeStorage(); } } } @@ -109,37 +75,43 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void dispose() { _pulseController.dispose(); - _refreshTimer?.cancel(); // MUY IMPORTANTE: cancelar timer para evitar leaks + _refreshTimer?.cancel(); _nuevaDireccionController.dispose(); super.dispose(); } // ---------------------------------------------------------------- - // CARGAR USUARIO ID DESDE STORAGE (fallback) + // INICIALIZACIÓN // ---------------------------------------------------------------- - Future _cargarUsuarioDeStorage() async { + + Future _inicializar() async { + // Carga el nombre desde prefs inmediatamente (sin esperar la API) + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() => _nombreUsuario = prefs.getString('nombre') ?? ''); + } + _cargarETA(); + _iniciarAutoRefresh(); + _registrarFCMToken(); + _cargarUsuario(); + _cargarColonias(); + } + + Future _cargarDesdeStorage() async { final prefs = await SharedPreferences.getInstance(); final id = prefs.getInt('usuario_id'); if (id != null) { - setState(() => _usuarioId = id); - _cargarETA(); - _iniciarAutoRefresh(); - _cargarUsuario(); - _cargarColonias(); + _usuarioId = id; + _inicializar(); } else { - // No hay sesión, volver al login - if (mounted) { - Navigator.pushReplacementNamed(context, '/'); - } + if (mounted) Navigator.pushReplacementNamed(context, '/'); } } // ---------------------------------------------------------------- - // CARGAR ETA DESDE EL BACKEND - // - // Centralizado aquí para poder llamarlo tanto en init como - // en el auto-refresh y en el botón de recarga manual. + // CARGA DE DATOS // ---------------------------------------------------------------- + Future _cargarETA() async { if (!mounted) return; setState(() { @@ -148,13 +120,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); try { - // Intenta leer el usuario local guardado final prefs = await SharedPreferences.getInstance(); - final usuarioId = prefs.getInt('usuario_id') ?? 1; // Si no hay, usa el 1 - - // Llamada real al servicio + final usuarioId = prefs.getInt('usuario_id') ?? 1; final etaInfo = await _apiService.obtenerETA(usuarioId); - if (mounted) { setState(() { _etaInfo = etaInfo; @@ -162,25 +130,23 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } } catch (e) { - print("Error en el backend, usando datos de simulación: $e"); - - // 🚀 MOCK DE EMERGENCIA PARA LA HACKATÓN 🚀 - // Si el backend falla o da 404, le inventamos datos válidos a la interfaz + debugPrint('Error backend ETA: $e'); + // Mock de emergencia para demos/hackathon if (mounted) { setState(() { _etaInfo = ETAInfo( usuarioId: 1, colonia: "Centro", rutaNombre: "Ruta Poniente - Camión #4", - rutaStatus: "EN_PROGRESO", + rutaStatus: "EN_RUTA", gpsOk: true, etaTexto: "12 minutos aprox.", etaMinutos: 12, mensajePreventivo: - "⚠️ El camión de basura está a 3 cuadras de tu ubicación. ¡Prepara tus bolsas orgánicas!", + "⚠️ El camión está a 3 cuadras. ¡Prepara tus bolsas!", ); _cargando = false; - _error = null; // Nos aseguramos de limpiar cualquier error + _error = null; }); } } @@ -193,7 +159,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { if (mounted) { setState(() { _direcciones = usuario.direcciones; + // Actualizar nombre si el API devuelve uno diferente + if (usuario.nombre.isNotEmpty) _nombreUsuario = usuario.nombre; }); + // Guardar nombre actualizado en prefs + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('nombre', usuario.nombre); } } catch (e) { debugPrint('Error cargando usuario: $e'); @@ -219,37 +190,215 @@ class _HomeScreenState extends State with TickerProviderStateMixin { 'San Juanico', 'Los Olivos', 'Rancho Seco', - 'Las Insurgentes', + 'Las Insurgentes' ]; _cargandoColonias = false; - _errorColonias = - 'No fue posible cargar colonias del servidor. Usando lista local.'; + _errorColonias = 'Sin conexión. Usando lista local.'; }); } } } + void _iniciarAutoRefresh() { + _refreshTimer = + Timer.periodic(const Duration(seconds: 60), (_) => _cargarETA()); + } + + // ---------------------------------------------------------------- + // FIREBASE + // ---------------------------------------------------------------- + Future _registrarFCMToken() async { + try { + final messaging = FirebaseMessaging.instance; + final settings = await messaging.requestPermission( + alert: true, sound: true, badge: true); + if (settings.authorizationStatus == AuthorizationStatus.authorized) { + final token = await messaging.getToken(); + if (token != null && _usuarioId != null) { + await _apiService.registrarFcmToken(_usuarioId!, token); + } + } + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + if (message.notification != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('🚛 ${message.notification!.body}'), + backgroundColor: Colors.green.shade700, + duration: const Duration(seconds: 5), + )); + _cargarETA(); + } + }); + } catch (e) { + debugPrint('Error FCM: $e'); + } + } + + // ---------------------------------------------------------------- + // CERRAR SESIÓN — Con dialog de confirmación + // ---------------------------------------------------------------- + Future _confirmarCerrarSesion() async { + final confirmar = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cerrar sesión'), + content: const Text('¿Seguro que quieres cerrar sesión?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: + ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700), + child: const Text('Cerrar sesión', + style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirmar == true) { + _refreshTimer?.cancel(); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + if (mounted) Navigator.pushReplacementNamed(context, '/'); + } + } + + // ---------------------------------------------------------------- + // CAMBIAR CONTRASEÑA + // ---------------------------------------------------------------- + Future _mostrarCambioPassword() async { + final actualCtrl = TextEditingController(); + final nuevoCtrl = TextEditingController(); + bool mostrarActual = false; + bool mostrarNuevo = false; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text('Cambiar contraseña'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: actualCtrl, + obscureText: !mostrarActual, + decoration: InputDecoration( + labelText: 'Contraseña actual', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(mostrarActual + ? Icons.visibility_off + : Icons.visibility), + onPressed: () => + setDialogState(() => mostrarActual = !mostrarActual), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + const SizedBox(height: 16), + TextField( + controller: nuevoCtrl, + obscureText: !mostrarNuevo, + decoration: InputDecoration( + labelText: 'Nueva contraseña', + hintText: 'Mínimo 6 caracteres', + prefixIcon: const Icon(Icons.lock_reset), + suffixIcon: IconButton( + icon: Icon(mostrarNuevo + ? Icons.visibility_off + : Icons.visibility), + onPressed: () => + setDialogState(() => mostrarNuevo = !mostrarNuevo), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () async { + if (_usuarioId == null) return; + final actual = actualCtrl.text; + final nuevo = nuevoCtrl.text; + if (actual.isEmpty || nuevo.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Completa ambos campos.')), + ); + return; + } + if (nuevo.length < 6) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'La nueva contraseña debe tener al menos 6 caracteres.')), + ); + return; + } + try { + await _apiService.actualizarPassword( + _usuarioId!, actual, nuevo); + if (mounted) { + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('✅ Contraseña actualizada correctamente.'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + final msg = e.toString().replaceFirst('Exception: ', ''); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: Colors.red.shade700), + ); + } + } + }, + child: const Text('Guardar'), + ), + ], + ), + ), + ); + + actualCtrl.dispose(); + nuevoCtrl.dispose(); + } + + // ---------------------------------------------------------------- + // AGREGAR DIRECCIÓN + // ---------------------------------------------------------------- Future _agregarDireccion() async { if (_usuarioId == null) return; final messenger = ScaffoldMessenger.of(context); final direccion = _nuevaDireccionController.text.trim(); if (_nuevaColoniaSeleccionada == null) { - if (mounted) { - messenger.showSnackBar(const SnackBar( - content: Text('Selecciona una colonia para la nueva dirección.'), - )); - } + messenger.showSnackBar( + const SnackBar(content: Text('Selecciona una colonia.'))); return; } if (direccion.isEmpty) { - if (mounted) { - messenger.showSnackBar(const SnackBar( - content: Text('Ingresa la dirección.'), - )); - } + messenger + .showSnackBar(const SnackBar(content: Text('Ingresa la dirección.'))); return; } - try { await _apiService.agregarDireccion( _usuarioId!, _nuevaColoniaSeleccionada!, direccion); @@ -258,186 +407,99 @@ class _HomeScreenState extends State with TickerProviderStateMixin { await _cargarUsuario(); await _cargarETA(); if (mounted) { - messenger.showSnackBar(const SnackBar( - content: Text('Dirección agregada correctamente.'), - )); + messenger.showSnackBar( + const SnackBar(content: Text('✅ Dirección agregada.'))); } } catch (e) { if (mounted) { - messenger.showSnackBar(const SnackBar( - content: Text('No se pudo agregar la dirección.'), - )); + messenger.showSnackBar( + const SnackBar(content: Text('No se pudo agregar la dirección.'))); } } } Future _mostrarAgregarDireccionDialog() async { - if (_cargandoColonias) { - await _cargarColonias(); - } - + if (_cargandoColonias) await _cargarColonias(); if (!mounted) return; - _nuevaDireccionController.clear(); _nuevaColoniaSeleccionada = null; await showDialog( context: context, - builder: (context) { - return AlertDialog( - title: const Text('Agregar nueva dirección'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_errorColonias != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - _errorColonias!, + builder: (context) => AlertDialog( + title: const Text('Agregar nueva dirección'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_errorColonias != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_errorColonias!, style: TextStyle( - color: Colors.orange.shade700, fontSize: 12), - ), - ), - TextField( - controller: _nuevaDireccionController, - decoration: const InputDecoration( - labelText: 'Dirección', - hintText: 'Calle, número, colonia', - ), + color: Colors.orange.shade700, fontSize: 12)), ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _nuevaColoniaSeleccionada, - hint: const Text('Selecciona tu colonia'), - items: _colonias.map((colonia) { - return DropdownMenuItem( - value: colonia, child: Text(colonia)); - }).toList(), - onChanged: (valor) { - setState(() => _nuevaColoniaSeleccionada = valor); - }, - ), - ], - ), + TextField( + controller: _nuevaDireccionController, + decoration: const InputDecoration( + labelText: 'Dirección', hintText: 'Calle, número'), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _nuevaColoniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + items: _colonias + .map((c) => DropdownMenuItem(value: c, child: Text(c))) + .toList(), + onChanged: (v) => setState(() => _nuevaColoniaSeleccionada = v), + ), + ], ), - actions: [ - TextButton( + ), + actions: [ + TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancelar'), - ), - ElevatedButton( - onPressed: () async { - final navigator = Navigator.of(context); - await _agregarDireccion(); - if (mounted) navigator.pop(); - }, - child: const Text('Guardar'), - ), - ], - ); - }, + child: const Text('Cancelar')), + ElevatedButton( + onPressed: () async { + final navigator = Navigator.of(context); + await _agregarDireccion(); + if (mounted) navigator.pop(); + }, + child: const Text('Guardar'), + ), + ], + ), ); } - // ---------------------------------------------------------------- - // AUTO-REFRESH CADA 60 SEGUNDOS - // - // Simula que el ETA se actualiza en tiempo real sin que el - // usuario tenga que hacer pull-to-refresh manualmente. - // En producción: usar WebSockets o Server-Sent Events. - // ---------------------------------------------------------------- - void _iniciarAutoRefresh() { - _refreshTimer = Timer.periodic( - const Duration(seconds: 60), - (_) => _cargarETA(), - ); - } - - // ---------------------------------------------------------------- - // REGISTRAR FCM TOKEN EN EL BACKEND - // - // Obtiene el token único de este dispositivo de Firebase y lo - // manda al backend para poder recibir notificaciones push. - // DESCOMENTA cuando tengas Firebase configurado. - // ---------------------------------------------------------------- - Future _registrarFCMToken() async { - try { - final messaging = FirebaseMessaging.instance; - - // Pedir permisos de notificación al usuario (iOS requiere esto) - final settings = await messaging.requestPermission( - alert: true, - sound: true, - badge: true, - ); - if (settings.authorizationStatus == AuthorizationStatus.authorized) { - final token = await messaging.getToken(); - if (token != null && _usuarioId != null) { - await _apiService.registrarFcmToken(_usuarioId!, token); - debugPrint('✅ FCM Token registrado: ${token.substring(0, 20)}...'); - } - } - // Escuchar notificaciones cuando la app está en FOREGROUND - FirebaseMessaging.onMessage.listen((RemoteMessage message) { - if (message.notification != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('🚛 ${message.notification!.body}'), - backgroundColor: Colors.green.shade700, - duration: const Duration(seconds: 5), - ), - ); - // Refrescar ETA al recibir notificación - _cargarETA(); - } - }); - } catch (e) { - debugPrint('Error registrando FCM token: $e'); - } - } - - // ---------------------------------------------------------------- - // CERRAR SESIÓN - // ---------------------------------------------------------------- - Future _cerrarSesion() async { - _refreshTimer?.cancel(); - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - if (mounted) { - Navigator.pushReplacementNamed(context, '/'); - } - } - // ================================================================ // HELPERS DE UI // ================================================================ - // Determina el color del fondo según el ETA (urgencia visual) - Color _colorSegunETA(int etaMinutos) { - if (etaMinutos <= 5) return const Color(0xFFB71C1C); // Rojo: ¡URGENTE! - if (etaMinutos <= 15) return const Color(0xFFF57F17); // Naranja: Pronto - if (etaMinutos <= 30) return const Color(0xFF1B5E20); // Verde: Con tiempo - return const Color(0xFF1A237E); // Azul: Tranquilo + Color _colorSegunETA(int eta) { + if (eta <= 5) return const Color(0xFFB71C1C); + if (eta <= 15) return const Color(0xFFF57F17); + if (eta <= 30) return const Color(0xFF1B5E20); + return const Color(0xFF1A237E); } - // Emoji indicador de urgencia - String _emojiSegunETA(int etaMinutos) { - if (etaMinutos <= 5) return '🔴'; - if (etaMinutos <= 15) return '🟡'; - if (etaMinutos <= 30) return '🟢'; + String _emojiSegunETA(int eta) { + if (eta <= 5) return '🔴'; + if (eta <= 15) return '🟡'; + if (eta <= 30) return '🟢'; return '🔵'; } // ================================================================ - // BUILD PRINCIPAL + // BUILD // ================================================================ + @override Widget build(BuildContext context) { return Scaffold( body: AnimatedContainer( duration: const Duration(milliseconds: 800), - // El fondo cambia de color según la urgencia del ETA decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -445,195 +507,320 @@ class _HomeScreenState extends State with TickerProviderStateMixin { colors: _etaInfo != null ? [ _colorSegunETA(_etaInfo!.etaMinutos), - _colorSegunETA(_etaInfo!.etaMinutos).withValues(alpha: 0.7), + _colorSegunETA(_etaInfo!.etaMinutos).withValues(alpha: 0.7) ] : [const Color(0xFF2E7D32), const Color(0xFF1B5E20)], ), ), child: SafeArea( - child: SingleChildScrollView( - // 🚀 AGREGA ESTE WIDGET AQUÍ - physics: - const BouncingScrollPhysics(), // Da un efecto de rebote suave en Android - child: _buildContenido(), - ), + child: _cargando + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 16), + Text('Consultando estado del camión...', + style: + TextStyle(color: Colors.white70, fontSize: 16)), + ])) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.wifi_off_rounded, + size: 80, color: Colors.white54), + const SizedBox(height: 16), + Text(_error!, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, fontSize: 18)), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: _cargarETA, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.red.shade700), + ), + ]), + )) + : _buildUIConDatos(), ), ), ); } - Widget _buildContenido() { - // Estado: Cargando - if (_cargando) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(color: Colors.white), - SizedBox(height: 16), - Text( - 'Consultando estado del camión...', - style: TextStyle(color: Colors.white70, fontSize: 16), - ), - ], - ), - ); - } - - // Estado: Error - if (_error != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.wifi_off_rounded, - size: 80, color: Colors.white54), - const SizedBox(height: 16), - Text( - _error!, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 18), - ), - const SizedBox(height: 32), - ElevatedButton.icon( - onPressed: _cargarETA, - icon: const Icon(Icons.refresh), - label: const Text('Reintentar'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.red.shade700, - ), - ), - ], - ), - ), - ); - } - - // Estado: Con datos - UI principal - return _buildUIConDatos(); - } - - // ---------------------------------------------------------------- - // UI PRINCIPAL CON DATOS DE ETA - // - // DECISIÓN DE DISEÑO: El mensaje preventivo ocupa 60% de la - // pantalla porque es lo más importante. El usuario debe verlo - // de un vistazo, sin lentes y desde lejos. - // ---------------------------------------------------------------- Widget _buildUIConDatos() { if (_etaInfo == null) return const SizedBox.shrink(); final eta = _etaInfo!; - return Column( - children: [ - // -------------------------------------------------------- - // HEADER: Barra superior con colonia y botón de logout - // -------------------------------------------------------- - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Colonia del usuario - Row( - children: [ - const Icon(Icons.location_on, - color: Colors.white70, size: 18), - const SizedBox(width: 4), - Text( - eta.colonia, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - ], - ), - Row( - children: [ - IconButton( - onPressed: () { - Navigator.pushNamed(context, '/routes'); - }, - icon: const Icon(Icons.list_alt, color: Colors.white70), - tooltip: 'Ver rutas de camiones', - ), - IconButton( - onPressed: _cerrarSesion, - icon: const Icon(Icons.logout, color: Colors.white70), - tooltip: 'Cerrar sesión', - ), - ], - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(14), - ), + return SingleChildScrollView( + child: Column( + children: [ + // ── HEADER ────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + // Saludo con nombre Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - eta.rutaNombre, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - 'Status: ${eta.rutaStatus}', - style: const TextStyle( - color: Colors.white70, - fontSize: 13, + if (_nombreUsuario.isNotEmpty) + Text( + 'Hola, ${_nombreUsuario.split(' ').first} 👋', + style: const TextStyle( + color: Colors.white70, fontSize: 13), ), + Row( + children: [ + const Icon(Icons.location_on, + color: Colors.white70, size: 16), + const SizedBox(width: 4), + Text( + eta.colonia, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16), + ), + ], ), ], ), ), - const Icon(Icons.local_shipping_rounded, - color: Colors.white70, size: 24), + + // Menú de acciones + Row( + children: [ + IconButton( + onPressed: () => Navigator.pushNamed(context, '/routes'), + icon: const Icon(Icons.list_alt, color: Colors.white70), + tooltip: 'Ver rutas', + ), + // Menú de 3 puntos con opciones extra + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white70), + onSelected: (value) { + if (value == 'password') _mostrarCambioPassword(); + if (value == 'logout') _confirmarCerrarSesion(); + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'password', + child: Row(children: [ + Icon(Icons.lock_reset, size: 20), + SizedBox(width: 10), + Text('Cambiar contraseña'), + ]), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'logout', + child: Row(children: [ + Icon(Icons.logout, size: 20, color: Colors.red), + SizedBox(width: 10), + Text('Cerrar sesión', + style: TextStyle(color: Colors.red)), + ]), + ), + ], + ), + ], + ), ], ), ), - ), - if (!eta.gpsOk) + // ── INFO DE RUTA ───────────────────────────────────────── Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(eta.rutaNombre, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text('Status: ${eta.rutaStatus}', + style: const TextStyle( + color: Colors.white70, fontSize: 13)), + ], + ), + ), + const Icon(Icons.local_shipping_rounded, + color: Colors.white70, size: 24), + ], + ), + ), + ), + + // ── ALERTA GPS ─────────────────────────────────────────── + if (!eta.gpsOk) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.red.shade700.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.red.shade300), + ), + child: const Row(children: [ + Icon(Icons.gps_off, color: Colors.white70), + SizedBox(width: 10), + Expanded( + child: Text( + 'GPS del camión desconectado. Recibirás una alerta si persiste.', + style: TextStyle(color: Colors.white, fontSize: 14))), + ]), + ), + ), + + // ── CÍRCULO ETA ────────────────────────────────────────── + const SizedBox(height: 32), + ScaleTransition( + scale: _pulseAnimation, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.15), + border: Border.all(color: Colors.white, width: 3), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_emojiSegunETA(eta.etaMinutos), + style: const TextStyle(fontSize: 48)), + const SizedBox(height: 4), + Text('${eta.etaMinutos}', + style: const TextStyle( + fontSize: 64, + fontWeight: FontWeight.w900, + color: Colors.white, + height: 1)), + const Text('minutos', + style: TextStyle( + fontSize: 18, + color: Colors.white70, + fontWeight: FontWeight.w300)), + ]), + ), + ), + + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text(eta.etaTexto, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + color: Colors.white, + fontWeight: FontWeight.w500)), + ), + + // ── MENSAJE PREVENTIVO ─────────────────────────────────── + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 8)) + ], + ), + child: Text( + eta.mensajePreventivo, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: _colorSegunETA(eta.etaMinutos), + height: 1.3), + ), + ), + ), + + // ── DIRECCIONES REGISTRADAS ────────────────────────────── + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.red.shade700.withValues(alpha: 0.18), + color: Colors.white.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.red.shade300), ), - child: const Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.gps_off, color: Colors.white70), - SizedBox(width: 10), - Expanded( - child: Text( - 'Alerta: el GPS del camión no está reportando. Se enviará una notificación si el problema persiste.', - style: TextStyle(color: Colors.white, fontSize: 14), + const Text('Direcciones registradas', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + if (_direcciones.isEmpty) + const Text('No tienes direcciones registradas aún.', + style: TextStyle(color: Colors.white70, fontSize: 14)) + else + for (final d in _direcciones) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(d.colonia, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(d.direccion, + style: const TextStyle( + color: Colors.white70, fontSize: 13)), + ]), + ), + const SizedBox(height: 10), + ElevatedButton.icon( + onPressed: _mostrarAgregarDireccionDialog, + icon: const Icon(Icons.add_location_alt_outlined), + label: const Text('Agregar dirección'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.18), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), ), ), ], @@ -641,276 +828,102 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Direcciones registradas', - style: TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - if (_direcciones.isEmpty) - const Text( - 'No tienes direcciones registradas aún. Agrega una para mejorar tu ETA.', - style: TextStyle(color: Colors.white70, fontSize: 14), - ) - else - for (final direccion in _direcciones) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - direccion.colonia, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - direccion.direccion, - style: const TextStyle( - color: Colors.white70, fontSize: 13), - ), - ], - ), - ), - const SizedBox(height: 10), - ElevatedButton.icon( - onPressed: _mostrarAgregarDireccionDialog, - icon: const Icon(Icons.add_location_alt_outlined), - label: const Text('Agregar dirección'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white.withValues(alpha: 0.18), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 40), - - // -------------------------------------------------------- - // CENTRO: ETA Visual (el corazón de la pantalla) - // ScaleTransition aplica la animación de pulso al círculo - // -------------------------------------------------------- - ScaleTransition( - scale: _pulseAnimation, - child: Container( - width: 220, - height: 220, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - border: Border.all(color: Colors.white, width: 3), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _emojiSegunETA(eta.etaMinutos), - style: const TextStyle(fontSize: 48), - ), - const SizedBox(height: 4), - // Número de minutos — el dato más importante - Text( - '${eta.etaMinutos}', - style: const TextStyle( - fontSize: 64, - fontWeight: FontWeight.w900, - color: Colors.white, - height: 1, - ), - ), - const Text( - 'minutos', - style: TextStyle( - fontSize: 18, - color: Colors.white70, - fontWeight: FontWeight.w300, - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 24), - - // ETA en texto descriptivo - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - eta.etaTexto, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 22, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - - const SizedBox(height: 40), - - // -------------------------------------------------------- - // MENSAJE PREVENTIVO — El núcleo del producto - // - // Este es el mensaje que el usuario DEBE leer. Enorme, - // contrastado, en un card destacado. Sin distracciones. - // -------------------------------------------------------- - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Text( - // ¡ESTE ES EL MENSAJE PRINCIPAL DEL SISTEMA! - // Viene del backend (campo mensaje_preventivo) - // Ejemplos: - // "⏰ Prepárate, el camión llega pronto. No saques tu basura aún." - // "🚛 ¡El camión está muy cerca! Saca tu basura AHORA." - eta.mensajePreventivo, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - color: _colorSegunETA(eta.etaMinutos), - height: 1.3, + // ── INFO DE SEPARACIÓN ─────────────────────────────────── + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Información relevante', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold)), + SizedBox(height: 12), + Text( + '• Separa orgánicos y reciclables. No mezcles líquidos con plásticos.', + style: TextStyle(color: Colors.white70, fontSize: 14)), + SizedBox(height: 8), + Text( + '• Saca tu basura a la acera solo cuando recibas la alerta de proximidad.', + style: TextStyle(color: Colors.white70, fontSize: 14)), + SizedBox(height: 8), + Text( + '• Si el GPS del camión se desconecta, recibirás una alerta de seguimiento.', + style: TextStyle(color: Colors.white70, fontSize: 14)), + ], ), ), ), - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(18), + // ── BOTÓN VER RUTAS ────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, '/routes'), + icon: const Icon(Icons.local_shipping_rounded), + label: const Text('Ver estado de rutas y simular avance'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.green.shade900, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + ), + ), ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Información relevante', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.pushNamed(context, '/info'), + icon: const Icon(Icons.eco_rounded), + label: const Text('Información relevante'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.green.shade900, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), ), - SizedBox(height: 12), - Text( - '• Separa orgánicos y reciclables. No mezcles líquidos con bolsas de plástico.', - style: TextStyle(color: Colors.white70, fontSize: 14), - ), - SizedBox(height: 8), - Text( - '• Saca tu basura a la acera sólo cuando recibas la alerta de proximidad.', - style: TextStyle(color: Colors.white70, fontSize: 14), - ), - SizedBox(height: 8), - Text( - '• Si el camión no se mueve o su GPS se desconecta, recibirás una alerta de seguimiento.', - style: TextStyle(color: Colors.white70, fontSize: 14), - ), - ], - ), - ), - ), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pushNamed(context, '/routes'); - }, - icon: const Icon(Icons.local_shipping_rounded), - label: const Text('Ver estado de rutas y simular avance'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.green.shade900, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), ), ), ), - ), - - // ❌ AQUÍ ESTABA EL Spacer(flex: 2) QUE ROMPÍA LA UI - // 🚀 LO REEMPLAZAMOS POR UN ESPACIADO FIJO Y SEGURO: - const SizedBox(height: 32), - - // -------------------------------------------------------- - // FOOTER: Botón de refresh manual + última actualización - // -------------------------------------------------------- - Padding( - padding: const EdgeInsets.only(bottom: 32), - child: Column( - children: [ - // Indicador de auto-refresh - const Text( - '🔄 Se actualiza automáticamente cada minuto', - style: TextStyle(color: Colors.white54, fontSize: 12), - ), + // ── FOOTER: REFRESH ────────────────────────────────────── + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column(children: [ + const Text('🔄 Se actualiza automáticamente cada minuto', + style: TextStyle(color: Colors.white54, fontSize: 12)), const SizedBox(height: 12), - // Botón de refresh manual para demos / jueces impacientes OutlinedButton.icon( onPressed: _cargarETA, icon: const Icon(Icons.refresh, color: Colors.white), - label: const Text( - 'Actualizar ahora', - style: TextStyle(color: Colors.white), - ), + label: const Text('Actualizar ahora', + style: TextStyle(color: Colors.white)), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.white54), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + borderRadius: BorderRadius.circular(20)), ), ), - ], + ]), ), - ), - ], - ); // Fin de la Column principal de _buildUIConDatos() + ], + ), + ); } } diff --git a/aplicacion_hack/lib/screens/info_screen.dart b/aplicacion_hack/lib/screens/info_screen.dart new file mode 100644 index 0000000..48de350 --- /dev/null +++ b/aplicacion_hack/lib/screens/info_screen.dart @@ -0,0 +1,689 @@ +// ================================================================ +// lib/screens/info_screen.dart +// Pantalla de Información Relevante sobre Manejo de Residuos +// ================================================================ +// +// PROPÓSITO: +// Educar al usuario sobre separación, reciclaje y manejo +// correcto de residuos. Contenido cargado desde el backend +// con fallback local si no hay conexión. +// +// FLUJO: +// 1. Lista de tarjetas por categoría (vista principal) +// 2. Tap en tarjeta → detalle del artículo +// 3. Cada artículo tiene secciones + consejo rápido destacado +// +// NAVEGACIÓN: +// Agregar en main.dart: +// '/info': (context) => const InfoScreen(), +// Y en home_screen.dart el botón: +// Navigator.pushNamed(context, '/info') +// ================================================================ + +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +// ---------------------------------------------------------------- +// MODELOS +// ---------------------------------------------------------------- + +class Subseccion { + final String subtitulo; + final String texto; + + Subseccion({required this.subtitulo, required this.texto}); + + factory Subseccion.fromJson(Map json) => + Subseccion(subtitulo: json['subtitulo'], texto: json['texto']); +} + +class Articulo { + final String id; + final String categoria; + final String emoji; + final String titulo; + final String resumen; + final List contenido; + final String consejoRapido; + + Articulo({ + required this.id, + required this.categoria, + required this.emoji, + required this.titulo, + required this.resumen, + required this.contenido, + required this.consejoRapido, + }); + + factory Articulo.fromJson(Map json) => Articulo( + id: json['id'], + categoria: json['categoria'], + emoji: json['emoji'], + titulo: json['titulo'], + resumen: json['resumen'], + contenido: (json['contenido'] as List) + .map((s) => Subseccion.fromJson(s)) + .toList(), + consejoRapido: json['consejo_rapido'], + ); +} + +// ---------------------------------------------------------------- +// DATOS LOCALES DE FALLBACK +// Se usan si el backend no está disponible. +// ---------------------------------------------------------------- +const _articulosFallback = [ + { + "id": "separacion-basica", + "categoria": "Separación", + "emoji": "♻️", + "titulo": "Cómo separar correctamente tu basura", + "resumen": "La separación correcta es el primer paso para reciclar y reducir el impacto ambiental.", + "contenido": [ + {"subtitulo": "Residuos Orgánicos 🟤", "texto": "Restos de comida, cáscaras, posos de café. Bolsa oscura. Se convierten en composta."}, + {"subtitulo": "Inorgánicos Reciclables 🟡", "texto": "PET, cartón limpio, vidrio, latas. Bolsa transparente. Deben estar limpios y secos."}, + {"subtitulo": "No Reciclables 🔴", "texto": "Papel higiénico usado, pañales, colillas. Bolsa negra."}, + {"subtitulo": "Residuos Especiales ⚠️", "texto": "Pilas, medicamentos, electrónicos. NUNCA con basura regular. Lleva a puntos de acopio."}, + ], + "consejo_rapido": "Si vino de la naturaleza y se pudre → orgánico. Si es artificial y limpio → reciclable.", + }, + { + "id": "cuando-sacar", + "categoria": "Horarios", + "emoji": "⏰", + "titulo": "¿Cuándo sacar tu basura?", + "resumen": "Sacar la basura en el momento correcto evita plagas, malos olores y que el camión se la pierda.", + "contenido": [ + {"subtitulo": "El momento ideal", "texto": "Saca cuando recibas la alerta de 'Camión Cercano' en la app. El camión está a menos de 15 minutos."}, + {"subtitulo": "¿Por qué no de noche?", "texto": "Atrae fauna que rompe bolsas y dispersa residuos. El plástico se deteriora con la humedad."}, + {"subtitulo": "¿Si me lo pierdo?", "texto": "Guarda la basura hasta el siguiente día. Nunca dejes bolsas en la vía pública fuera del horario."}, + ], + "consejo_rapido": "Espera la alerta de la app antes de salir con tus bolsas.", + }, + { + "id": "plasticos-guia", + "categoria": "Reciclaje", + "emoji": "🧴", + "titulo": "Guía de plásticos: cuáles sí y cuáles no", + "resumen": "No todos los plásticos son iguales. Aprende a leer el número en el triángulo de reciclaje.", + "contenido": [ + {"subtitulo": "✅ #1 PET", "texto": "Botellas de agua y refresco. El más reciclado. Aplástalo y quita la tapa."}, + {"subtitulo": "✅ #2 HDPE", "texto": "Garrafones, botellas de leche, shampú. Enjuágalo antes."}, + {"subtitulo": "✅ #5 PP", "texto": "Tapas, envases de yogur. Sí se recicla pero menos centros lo aceptan."}, + {"subtitulo": "❌ #3, #6, #7", "texto": "PVC, unicel, policarbonato. Difíciles de reciclar. Van a basura general."}, + {"subtitulo": "❌ Bolsas de plástico", "texto": "No al reciclaje de casa. Lleva a centros de acopio en supermercados."}, + ], + "consejo_rapido": "Busca el número en el triángulo en el fondo del envase. #1 y #2 siempre al reciclaje.", + }, + { + "id": "residuos-peligrosos", + "categoria": "Residuos Especiales", + "emoji": "⚠️", + "titulo": "Residuos peligrosos: cómo deshacerte de ellos", + "resumen": "Pilas, medicamentos y electrónicos requieren manejo especial para no contaminar.", + "contenido": [ + {"subtitulo": "Pilas y baterías", "texto": "Una pila AA puede contaminar 600,000 litros de agua. Lleva a Walmart, Soriana o OXXO."}, + {"subtitulo": "Medicamentos caducados", "texto": "No al drenaje. Farmacias del Ahorro y Benavides tienen contenedores REPARED."}, + {"subtitulo": "Electrónicos (RAEE)", "texto": "Celulares, cables, focos LED. Contienen plomo y mercurio. Lleva a Best Buy o Liverpool."}, + {"subtitulo": "Aceite de cocina", "texto": "Un litro contamina 1,000 litros de agua. Guárdalo en botella PET y lleva a acopio."}, + ], + "consejo_rapido": "Guarda una caja en casa solo para residuos peligrosos. Cuando esté llena, busca el punto de acopio.", + }, + { + "id": "composta", + "categoria": "Compostaje", + "emoji": "🌱", + "titulo": "Haz composta en casa", + "resumen": "Convierte tus residuos orgánicos en abono natural. Es más fácil de lo que crees.", + "contenido": [ + {"subtitulo": "¿Qué necesitas?", "texto": "Un contenedor con tapa, residuos orgánicos, tierra o hojarasca y paciencia."}, + {"subtitulo": "¿Qué puedes compostar?", "texto": "Cáscaras de frutas, restos sin carne, posos de café, cáscaras de huevo, hojas secas."}, + {"subtitulo": "¿Qué NO?", "texto": "Carnes, lácteos, aceites (atraen plagas), excrementos de mascotas, plásticos."}, + {"subtitulo": "El proceso", "texto": "Alterna capas húmedas con secas. Voltea cada semana. En 2-3 meses tienes composta lista."}, + ], + "consejo_rapido": "La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega material seco.", + }, + { + "id": "impacto-ambiental", + "categoria": "Medio Ambiente", + "emoji": "🌍", + "titulo": "El impacto real de reciclar", + "resumen": "Números concretos para entender por qué vale la pena separar tu basura cada día.", + "contenido": [ + {"subtitulo": "Papel y cartón", "texto": "1 tonelada reciclada salva 17 árboles y ahorra 26,000 litros de agua."}, + {"subtitulo": "Aluminio", "texto": "Reciclar una lata ahorra energía para un foco LED por 20 horas. Se recicla infinitas veces."}, + {"subtitulo": "Vidrio", "texto": "Tarda 4,000 años en degradarse. Reciclarlo reduce 20% las emisiones de su producción."}, + {"subtitulo": "Residuos en México", "texto": "México genera 120,000 toneladas de basura al día. Solo el 9% se recicla. Podemos hacer más."}, + ], + "consejo_rapido": "Cada lata de aluminio reciclada ahorra energía equivalente a medio litro de gasolina. Sí importa.", + }, +]; + +// ---------------------------------------------------------------- +// COLORES POR CATEGORÍA +// ---------------------------------------------------------------- +Color _colorCategoria(String categoria) { + switch (categoria) { + case 'Separación': return const Color(0xFF2E7D32); + case 'Horarios': return const Color(0xFF1565C0); + case 'Reciclaje': return const Color(0xFF00838F); + case 'Compostaje': return const Color(0xFF558B2F); + case 'Residuos Especiales': return const Color(0xFFE65100); + case 'Medio Ambiente': return const Color(0xFF4527A0); + default: return const Color(0xFF37474F); + } +} + +// ================================================================ +// PANTALLA PRINCIPAL: Lista de artículos +// ================================================================ +class InfoScreen extends StatefulWidget { + const InfoScreen({super.key}); + + @override + State createState() => _InfoScreenState(); +} + +class _InfoScreenState extends State { + List _articulos = []; + bool _cargando = true; + String? _categoriaSeleccionada; + +static const String _baseUrl = 'http://192.168.198.224:8000'; + + @override + void initState() { + super.initState(); + _cargarArticulos(); + } + + Future _cargarArticulos() async { + try { + final response = await http + .get(Uri.parse('$_baseUrl/api/info')) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + // El endpoint de lista no trae contenido completo, + // así que cargamos desde fallback y enriquecemos con backend + final ids = (data['articulos'] as List).map((a) => a['id'] as String).toList(); + final articulos = []; + for (final fallback in _articulosFallback) { + if (ids.contains(fallback['id'])) { + articulos.add(Articulo.fromJson(fallback as Map)); + } + } + if (mounted) setState(() { _articulos = articulos; _cargando = false; }); + return; + } + } catch (_) { + // Fallback silencioso + } + + // Carga local si el backend no responde + if (mounted) { + setState(() { + _articulos = _articulosFallback + .map((a) => Articulo.fromJson(a as Map)) + .toList(); + _cargando = false; + }); + } + } + + List get _categorias => + _articulos.map((a) => a.categoria).toSet().toList(); + + List get _articulosFiltrados => _categoriaSeleccionada == null + ? _articulos + : _articulos.where((a) => a.categoria == _categoriaSeleccionada).toList(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F7F5), + appBar: AppBar( + title: const Text('Información relevante'), + backgroundColor: const Color(0xFF2E7D32), + foregroundColor: Colors.white, + elevation: 0, + ), + body: _cargando + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + // ── HEADER VERDE ───────────────────────────────── + Container( + width: double.infinity, + color: const Color(0xFF2E7D32), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: const Text( + 'Aprende a manejar tus residuos de forma responsable y reduce tu impacto ambiental.', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ), + + // ── FILTROS POR CATEGORÍA ───────────────────────── + if (_categorias.isNotEmpty) + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _FiltroChip( + label: 'Todos', + seleccionado: _categoriaSeleccionada == null, + color: const Color(0xFF2E7D32), + onTap: () => setState(() => _categoriaSeleccionada = null), + ), + ...(_categorias.map((cat) => _FiltroChip( + label: cat, + seleccionado: _categoriaSeleccionada == cat, + color: _colorCategoria(cat), + onTap: () => setState(() => _categoriaSeleccionada = cat), + ))), + ], + ), + ), + + // ── LISTA DE ARTÍCULOS ──────────────────────────── + Expanded( + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: _articulosFiltrados.length, + itemBuilder: (context, index) { + final articulo = _articulosFiltrados[index]; + return _TarjetaArticulo( + articulo: articulo, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _DetalleArticuloScreen(articulo: articulo), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +// ================================================================ +// WIDGET: Chip de filtro por categoría +// ================================================================ +class _FiltroChip extends StatelessWidget { + final String label; + final bool seleccionado; + final Color color; + final VoidCallback onTap; + + const _FiltroChip({ + required this.label, + required this.seleccionado, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: seleccionado ? color : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color, width: 1.5), + ), + child: Text( + label, + style: TextStyle( + color: seleccionado ? Colors.white : color, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ), + ); + } +} + +// ================================================================ +// WIDGET: Tarjeta de artículo en la lista +// ================================================================ +class _TarjetaArticulo extends StatelessWidget { + final Articulo articulo; + final VoidCallback onTap; + + const _TarjetaArticulo({required this.articulo, required this.onTap}); + + @override + Widget build(BuildContext context) { + final color = _colorCategoria(articulo.categoria); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barra de color superior + Container( + height: 6, + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Emoji grande + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text(articulo.emoji, style: const TextStyle(fontSize: 28)), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Badge de categoría + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + articulo.categoria, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(height: 6), + Text( + articulo.titulo, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 4), + Text( + articulo.resumen, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade400), + ], + ), + ), + // Consejo rápido al pie + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('💡', style: TextStyle(fontSize: 14, color: color)), + const SizedBox(width: 8), + Expanded( + child: Text( + articulo.consejoRapido, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ================================================================ +// PANTALLA DE DETALLE DE ARTÍCULO +// ================================================================ +class _DetalleArticuloScreen extends StatelessWidget { + final Articulo articulo; + + const _DetalleArticuloScreen({required this.articulo}); + + @override + Widget build(BuildContext context) { + final color = _colorCategoria(articulo.categoria); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7F5), + body: CustomScrollView( + slivers: [ + // ── APP BAR CON COLOR DE CATEGORÍA ─────────────────── + SliverAppBar( + expandedHeight: 160, + pinned: true, + backgroundColor: color, + foregroundColor: Colors.white, + flexibleSpace: FlexibleSpaceBar( + title: Text( + articulo.titulo, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, color.withValues(alpha: 0.7)], + ), + ), + child: Center( + child: Text(articulo.emoji, style: const TextStyle(fontSize: 64)), + ), + ), + ), + ), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Badge categoría + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + articulo.categoria, + style: TextStyle(color: color, fontWeight: FontWeight.w700, fontSize: 13), + ), + ), + const SizedBox(height: 12), + + // Resumen + Text( + articulo.resumen, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF333333), + height: 1.5, + ), + ), + const SizedBox(height: 24), + + // ── SECCIONES DE CONTENIDO ─────────────────── + ...articulo.contenido.map((seccion) => _SeccionCard( + seccion: seccion, + color: color, + )), + + const SizedBox(height: 8), + + // ── CONSEJO RÁPIDO DESTACADO ───────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Text('💡', style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text( + 'Consejo rápido', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + articulo.consejoRapido, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.5, + ), + ), + ], + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ================================================================ +// WIDGET: Tarjeta de una sección dentro del detalle +// ================================================================ +class _SeccionCard extends StatelessWidget { + final Subseccion seccion; + final Color color; + + const _SeccionCard({required this.seccion, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border(left: BorderSide(color: color, width: 4)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + seccion.subtitulo, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 6), + Text( + seccion.texto, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF444444), + height: 1.5, + ), + ), + ], + ), + ); + } +} diff --git a/aplicacion_hack/lib/screens/login_screen.dart b/aplicacion_hack/lib/screens/login_screen.dart index 71df8a2..1c0ac3c 100644 --- a/aplicacion_hack/lib/screens/login_screen.dart +++ b/aplicacion_hack/lib/screens/login_screen.dart @@ -1,20 +1,11 @@ // ================================================================ -// lib/screens/login_screen.dart -// Pantalla de Login Mockeada — Hackathon MVP +// lib/screens/login_screen.dart (v2) // ================================================================ // -// PROPÓSITO: -// Simular la selección de identidad de usuario para la demo. -// En producción aquí iría: Google Sign-In, OTP por SMS, etc. -// -// FLUJO: -// 1. Usuario ingresa un ID numérico (1-4 para los seed data) -// 2. Selecciona su colonia en un Dropdown -// 3. Presiona "Entrar" -> navega a HomeScreen con el usuario_id -// -// ATAJO DE HACKATHON: -// El "ID de usuario" es manual para evitar un sistema de auth -// completo. Para la demo, los IDs 1-4 son los del seed. +// CAMBIOS v2: +// - loginConCorreo ahora devuelve también el nombre del usuario +// - El nombre se guarda en SharedPreferences para mostrarlo en home +// - Mensaje de error más específico (viene del backend) // ================================================================ import 'package:flutter/material.dart'; @@ -29,40 +20,21 @@ class LoginScreen extends StatefulWidget { } class _LoginScreenState extends State { - // ---------------------------------------------------------------- - // ESTADO LOCAL - // ---------------------------------------------------------------- - - // Controladores para los campos de email/registro final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); final TextEditingController _nameController = TextEditingController(); final TextEditingController _direccionController = TextEditingController(); - // Colonia seleccionada en el Dropdown (null = no seleccionada aún) String? _coloniaSeleccionada; - - // Lista de colonias cargadas desde el backend List _colonias = []; - - // Indica si estamos en modo registro o en modo login bool _esRegistro = false; - - // Estado de carga: mostramos spinner mientras cargamos colonias bool _cargandoColonias = true; - - // Estado de error al cargar colonias String? _errorColonias; - - // Estado del botón de login: evita doble tap bool _logueando = false; + bool _mostrarPassword = false; - // Servicio de API (instancia local, sin inyección para el hackathon) final ApiService _apiService = ApiService(); - // ---------------------------------------------------------------- - // LIFECYCLE - // ---------------------------------------------------------------- - @override void initState() { super.initState(); @@ -72,40 +44,24 @@ class _LoginScreenState extends State { @override void dispose() { - // Siempre liberar controllers para evitar memory leaks _emailController.dispose(); + _passwordController.dispose(); _nameController.dispose(); _direccionController.dispose(); super.dispose(); } // ---------------------------------------------------------------- - // VERIFICAR SESIÓN EXISTENTE - // - // Si el usuario ya se logueó antes (guardado en shared_preferences), - // lo mandamos directo al home sin pasar por el login. - // ATAJO: Esto simula "recordar sesión". No es auth real. + // Si ya hay sesión guardada, saltamos el login directamente. // ---------------------------------------------------------------- Future _verificarSesionExistente() async { final prefs = await SharedPreferences.getInstance(); - final usuarioIdGuardado = prefs.getInt('usuario_id'); - - if (usuarioIdGuardado != null && mounted) { - // Ya hay sesión, ir al home directamente - Navigator.pushReplacementNamed( - context, - '/home', - arguments: usuarioIdGuardado, - ); + final usuarioId = prefs.getInt('usuario_id'); + if (usuarioId != null && mounted) { + Navigator.pushReplacementNamed(context, '/home', arguments: usuarioId); } } - // ---------------------------------------------------------------- - // CARGAR COLONIAS DESDE EL BACKEND - // - // Intenta cargar desde la API. Si falla (backend apagado), - // usa una lista de fallback hardcodeada para no bloquear la demo. - // ---------------------------------------------------------------- Future _cargarColonias() async { try { final colonias = await _apiService.obtenerColonias(); @@ -116,18 +72,11 @@ class _LoginScreenState extends State { }); } } catch (e) { - // FALLBACK: Lista hardcodeada por si el backend no está corriendo - // Útil para desarrollar el frontend en paralelo al backend if (mounted) { setState(() { _colonias = [ - 'Zona Centro', - 'Col. Hidalgo', - 'Col. Independencia', - 'Col. Obrera', - 'Col. San Juan', - 'Fracc. Los Pinos', - 'Col. Reforma', + 'Zona Centro', 'Las Arboledas', 'Trojes', + 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes', ]; _cargandoColonias = false; _errorColonias = 'Sin conexión al backend. Usando lista local.'; @@ -137,100 +86,77 @@ class _LoginScreenState extends State { } // ---------------------------------------------------------------- - // ACCIÓN: INICIAR SESIÓN - // Valida el correo, llama al backend y navega. + // LOGIN // ---------------------------------------------------------------- Future _iniciarSesion() async { final email = _emailController.text.trim(); - if (email.isEmpty) { - _mostrarError('Por favor ingresa tu correo.'); - return; - } + final password = _passwordController.text; + + if (email.isEmpty) { _mostrarError('Por favor ingresa tu correo.'); return; } + if (password.isEmpty) { _mostrarError('Por favor ingresa tu contraseña.'); return; } setState(() => _logueando = true); try { - final usuarioId = await _apiService.loginConCorreo(email); + // v2: devuelve {usuario_id, nombre} + final resultado = await _apiService.loginConCorreo(email, password); final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('usuario_id', usuarioId); + await prefs.setInt('usuario_id', resultado['usuario_id']); + await prefs.setString('nombre', resultado['nombre'] ?? ''); await prefs.setString('email', email); if (mounted) { - Navigator.pushReplacementNamed( - context, - '/home', - arguments: usuarioId, - ); + Navigator.pushReplacementNamed(context, '/home', arguments: resultado['usuario_id']); } } catch (e) { - _mostrarError('Error iniciando sesión. Revisa tu correo o regístrate.'); + // Muestra el mensaje de error que viene del backend (más específico) + final mensaje = e.toString().replaceFirst('Exception: ', ''); + _mostrarError(mensaje); } finally { - if (mounted) { - setState(() => _logueando = false); - } + if (mounted) setState(() => _logueando = false); } } // ---------------------------------------------------------------- - // ACCIÓN: REGISTRARSE - // Valida los datos y crea un nuevo usuario en el backend. + // REGISTRO // ---------------------------------------------------------------- Future _registrarse() async { final nombre = _nameController.text.trim(); final email = _emailController.text.trim(); + final password = _passwordController.text; final direccion = _direccionController.text.trim(); - if (nombre.isEmpty) { - _mostrarError('Por favor ingresa tu nombre.'); - return; - } - if (email.isEmpty) { - _mostrarError('Por favor ingresa tu correo.'); - return; - } - if (_coloniaSeleccionada == null) { - _mostrarError('Por favor selecciona tu colonia.'); - return; - } - if (direccion.isEmpty) { - _mostrarError('Por favor ingresa tu dirección.'); - return; - } + if (nombre.isEmpty) { _mostrarError('Por favor ingresa tu nombre.'); return; } + if (email.isEmpty) { _mostrarError('Por favor ingresa tu correo.'); return; } + if (password.isEmpty) { _mostrarError('Por favor ingresa tu contraseña.'); return; } + if (password.length < 6) { _mostrarError('La contraseña debe tener al menos 6 caracteres.'); return; } + if (_coloniaSeleccionada == null) { _mostrarError('Por favor selecciona tu colonia.'); return; } + if (direccion.isEmpty) { _mostrarError('Por favor ingresa tu dirección.'); return; } setState(() => _logueando = true); try { final usuarioId = await _apiService.registrarUsuario( - nombre, - email, - direccion, - _coloniaSeleccionada!, + nombre, email, password, direccion, _coloniaSeleccionada!, ); final prefs = await SharedPreferences.getInstance(); await prefs.setInt('usuario_id', usuarioId); + await prefs.setString('nombre', nombre); await prefs.setString('email', email); - await prefs.setString('colonia', _coloniaSeleccionada!); if (mounted) { - Navigator.pushReplacementNamed( - context, - '/home', - arguments: usuarioId, - ); + Navigator.pushReplacementNamed(context, '/home', arguments: usuarioId); } } catch (e) { - _mostrarError('Error registrando usuario. Intenta con otro correo.'); + final mensaje = e.toString().replaceFirst('Exception: ', ''); + _mostrarError(mensaje); } finally { - if (mounted) { - setState(() => _logueando = false); - } + if (mounted) setState(() => _logueando = false); } } - // ---------------------------------------------------------------- - // HELPER: Mostrar mensaje de error con SnackBar - // ---------------------------------------------------------------- void _mostrarError(String mensaje) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(mensaje), @@ -241,220 +167,232 @@ class _LoginScreenState extends State { } // ================================================================ - // UI + // BUILD // ================================================================ @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: colorScheme.surface, body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ------------------------------------------------ - // HEADER: Ícono y título - // ------------------------------------------------ - Icon( - Icons.recycling_rounded, - size: 80, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + + // ── LOGO ────────────────────────────────────────── + Icon(Icons.recycling_rounded, size: 80, color: colorScheme.primary), + const SizedBox(height: 16), + Text( + 'Recolección\nInteligente', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, color: colorScheme.primary, ), - const SizedBox(height: 16), - Text( - 'Recolección\nInteligente', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - const SizedBox(height: 8), - Text( - 'Ingresa tus datos para recibir notificaciones de tu camión', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 48), + ), + const SizedBox(height: 8), + Text( + _esRegistro + ? 'Regístrate para recibir alertas del camión' + : 'Inicia sesión para ver el estado del camión', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600), + ), + const SizedBox(height: 40), - // ------------------------------------------------ - // FORMULARIO: Correo / Registro - // ------------------------------------------------ + // ── CAMPOS COMUNES ──────────────────────────────── + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: 'Correo electrónico', + hintText: 'usuario@ejemplo.com', + prefixIcon: const Icon(Icons.email_outlined), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: !_mostrarPassword, + decoration: InputDecoration( + labelText: 'Contraseña', + hintText: '••••••••', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_mostrarPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _mostrarPassword = !_mostrarPassword), + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + + // ── CAMPOS EXTRA (solo en registro) ─────────────── + if (_esRegistro) ...[ TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, + controller: _nameController, + textCapitalization: TextCapitalization.words, decoration: InputDecoration( - labelText: 'Correo electrónico', - hintText: 'usuario@ejemplo.com', - prefixIcon: const Icon(Icons.email_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), + labelText: 'Nombre completo', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), ), ), const SizedBox(height: 16), - - if (_esRegistro) ...[ - TextField( - controller: _nameController, - decoration: InputDecoration( - labelText: 'Nombre completo', - prefixIcon: const Icon(Icons.person_outline), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + TextField( + controller: _direccionController, + decoration: InputDecoration( + labelText: 'Dirección', + hintText: 'Calle, número', + prefixIcon: const Icon(Icons.home_outlined), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), ), - const SizedBox(height: 16), - TextField( - controller: _direccionController, - decoration: InputDecoration( - labelText: 'Dirección', - hintText: 'Calle, número, colonia', - prefixIcon: const Icon(Icons.home_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), + ), + const SizedBox(height: 16), + if (_cargandoColonias) + const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator())) + else ...[ + if (_errorColonias != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text('⚠️ $_errorColonias', + style: TextStyle(fontSize: 12, color: Colors.orange.shade700)), ), + DropdownButtonFormField( + value: _coloniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.location_city_outlined), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(), + onChanged: (valor) => setState(() => _coloniaSeleccionada = valor), ), - const SizedBox(height: 20), ], - - if (_esRegistro) - if (_cargandoColonias) - const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: CircularProgressIndicator(), - ), - ) - else ...[ - if (_errorColonias != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - '⚠️ $_errorColonias', - style: TextStyle( - fontSize: 12, - color: Colors.orange.shade700, - ), - ), - ), - DropdownButtonFormField( - initialValue: _coloniaSeleccionada, - hint: const Text('Selecciona tu colonia'), - decoration: InputDecoration( - prefixIcon: const Icon(Icons.location_city_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - items: _colonias.map((colonia) { - return DropdownMenuItem( - value: colonia, - child: Text(colonia), - ); - }).toList(), - onChanged: (valor) { - setState(() => _coloniaSeleccionada = valor); - }, - ), - ], - const SizedBox(height: 32), - - // ------------------------------------------------ - // BOTÓN: Entrar / Registrarse - // Muestra spinner mientras _logueando == true - // ------------------------------------------------ - SizedBox( - height: 56, - child: ElevatedButton( - onPressed: _logueando - ? null - : _esRegistro - ? _registrarse - : _iniciarSesion, - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _logueando - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - _esRegistro ? 'Registrarse' : 'Iniciar sesión', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - - const SizedBox(height: 12), - TextButton( - onPressed: _logueando - ? null - : () { - setState(() { - _esRegistro = !_esRegistro; - // Clear fields when switching modes - _nameController.clear(); - _direccionController.clear(); - _coloniaSeleccionada = null; - }); - }, - child: Text( - _esRegistro - ? '¿Ya tienes cuenta? Inicia sesión' - : '¿No tienes cuenta? Regístrate', - style: TextStyle( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _esRegistro - ? 'Regístrate con tu correo, nombre y dirección para recibir avisos de recolección.' - : 'Inicia sesión con tu correo para ver el estado del camión y recibir notificaciones.', - style: TextStyle( - fontSize: 12, - color: colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - ), + const SizedBox(height: 8), + // Indicador de fortaleza de contraseña + _PasswordStrengthIndicator(password: _passwordController.text), + const SizedBox(height: 8), ], - ), + + const SizedBox(height: 24), + + // ── BOTÓN PRINCIPAL ─────────────────────────────── + SizedBox( + height: 56, + child: ElevatedButton( + onPressed: _logueando ? null : (_esRegistro ? _registrarse : _iniciarSesion), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _logueando + ? const SizedBox( + height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Text( + _esRegistro ? 'Registrarse' : 'Iniciar sesión', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + + const SizedBox(height: 12), + + // ── TOGGLE LOGIN/REGISTRO ───────────────────────── + TextButton( + onPressed: _logueando + ? null + : () => setState(() { + _esRegistro = !_esRegistro; + _nameController.clear(); + _direccionController.clear(); + _coloniaSeleccionada = null; + _passwordController.clear(); + }), + child: Text( + _esRegistro ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate', + style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600), + ), + ), + + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _esRegistro + ? 'Tu contraseña se almacena de forma segura (bcrypt). Mínimo 6 caracteres.' + : 'Inicia sesión con tu correo para ver el estado del camión y recibir notificaciones.', + style: TextStyle(fontSize: 12, color: colorScheme.primary), + textAlign: TextAlign.center, + ), + ), + ], ), ), ), ); } } + +// ================================================================ +// WIDGET: Indicador de fortaleza de contraseña +// Muestra una barra de color que se llena según la complejidad. +// Solo se muestra en el modo registro. +// ================================================================ +class _PasswordStrengthIndicator extends StatelessWidget { + final String password; + const _PasswordStrengthIndicator({required this.password}); + + // Retorna (nivel 0-3, etiqueta, color) + (int, String, Color) _evaluar() { + if (password.isEmpty) return (0, '', Colors.grey); + int puntos = 0; + if (password.length >= 8) puntos++; + if (password.contains(RegExp(r'[A-Z]'))) puntos++; + if (password.contains(RegExp(r'[0-9]'))) puntos++; + if (password.contains(RegExp(r'[!@#\$%^&*]'))) puntos++; + + if (puntos <= 1) return (1, 'Débil', Colors.red); + if (puntos == 2) return (2, 'Regular', Colors.orange); + if (puntos == 3) return (3, 'Buena', Colors.lightGreen); + return (4, 'Excelente', Colors.green); + } + + @override + Widget build(BuildContext context) { + if (password.isEmpty) return const SizedBox.shrink(); + final (nivel, etiqueta, color) = _evaluar(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: List.generate(4, (i) { + return Expanded( + child: Container( + margin: const EdgeInsets.only(right: 4), + height: 4, + decoration: BoxDecoration( + color: i < nivel ? color : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), + ), + const SizedBox(height: 4), + Text('Contraseña: $etiqueta', style: TextStyle(fontSize: 12, color: color)), + ], + ); + } +} diff --git a/aplicacion_hack/lib/screens/route_list_screen.dart b/aplicacion_hack/lib/screens/route_list_screen.dart index c775cbc..56f0968 100644 --- a/aplicacion_hack/lib/screens/route_list_screen.dart +++ b/aplicacion_hack/lib/screens/route_list_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../services/api_service.dart'; class RouteListScreen extends StatefulWidget { @@ -14,11 +15,30 @@ class _RouteListScreenState extends State { bool _avanzando = false; String? _error; List _rutas = []; + int? _usuarioId; @override void initState() { super.initState(); - _cargarRutas(); + _cargarUsuarioYRutas(); + } + + Future _cargarUsuarioYRutas() async { + final prefs = await SharedPreferences.getInstance(); + final usuarioId = prefs.getInt('usuario_id'); + + if (usuarioId == null) { + if (mounted) { + setState(() { + _error = 'No se encontró sesión activa. Por favor inicia sesión de nuevo.'; + _cargando = false; + }); + } + return; + } + + _usuarioId = usuarioId; + await _cargarRutas(); } Future _cargarRutas() async { @@ -27,8 +47,18 @@ class _RouteListScreenState extends State { _error = null; }); + if (_usuarioId == null) { + if (mounted) { + setState(() { + _error = 'No se encontró sesión activa. Por favor inicia sesión de nuevo.'; + _cargando = false; + }); + } + return; + } + try { - final rutas = await _apiService.obtenerRutas(); + final rutas = await _apiService.obtenerRutas(_usuarioId!); if (mounted) { setState(() { _rutas = rutas; @@ -54,8 +84,20 @@ class _RouteListScreenState extends State { _avanzando = true; }); + if (_usuarioId == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No se encontró sesión activa. Inicia sesión de nuevo.'), + backgroundColor: Colors.redAccent, + ), + ); + } + return; + } + try { - final rutaActualizada = await _apiService.avanzarRuta(routeId); + final rutaActualizada = await _apiService.avanzarRuta(routeId, _usuarioId!); if (mounted) { setState(() { final index = _rutas.indexWhere((r) => r.routeId == routeId); diff --git a/aplicacion_hack/lib/services/api_service.dart b/aplicacion_hack/lib/services/api_service.dart index 7ca49d6..945df46 100644 --- a/aplicacion_hack/lib/services/api_service.dart +++ b/aplicacion_hack/lib/services/api_service.dart @@ -1,30 +1,22 @@ // ================================================================ -// lib/services/api_service.dart +// lib/services/api_service.dart (v2) // 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); +// 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; // ---------------------------------------------------------------- -// MODELO: ETAInfo -// Representa la respuesta del endpoint GET /api/eta/{usuario_id} -// Mapea exactamente los campos que devuelve el backend (main.py) +// MODELOS // ---------------------------------------------------------------- + class ETAInfo { final int usuarioId; final String colonia; @@ -46,9 +38,6 @@ class ETAInfo { 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'], @@ -67,16 +56,10 @@ class DireccionInfo { final String colonia; final String direccion; - DireccionInfo({ - required this.colonia, - required this.direccion, - }); + DireccionInfo({required this.colonia, required this.direccion}); factory DireccionInfo.fromJson(Map json) { - return DireccionInfo( - colonia: json['colonia'], - direccion: json['direccion'], - ); + return DireccionInfo(colonia: json['colonia'], direccion: json['direccion']); } } @@ -134,237 +117,367 @@ class RouteInfo { } } +/// 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 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. + // 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.192.96:8000'; - - - // Timeout razonable para demo. Si el backend es lento, sube a 15s. + static const String _baseUrl = 'http://192.168.198.224:8000'; 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. + // HELPER PRIVADO: maneja errores HTTP de forma consistente // ---------------------------------------------------------------- - Future obtenerETA(int usuarioId) async { - final url = Uri.parse('$_baseUrl/api/eta/$usuarioId'); - + Never _throwError(http.Response response) { + Map body = {}; 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; - } + body = json.decode(response.body); + } catch (_) {} + final detail = body['detail'] ?? response.body; + throw Exception(detail); } - // ---------------------------------------------------------------- - // 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'); + // ================================================================ + // AUTENTICACIÓN + // ================================================================ + /// Login con email y contraseña. Retorna [usuarioId, nombre]. + Future> loginConCorreo(String email, String password) async { final response = await http.post( - url, + Uri.parse('$_baseUrl/api/usuarios/login'), headers: {'Content-Type': 'application/json'}, - body: json.encode({'email': email.trim().toLowerCase()}), + body: json.encode({'email': email.trim().toLowerCase(), 'password': password}), ).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}'); + final data = json.decode(response.body); + return {'usuario_id': data['usuario_id'], 'nombre': data['nombre']}; } + _throwError(response); } - // ---------------------------------------------------------------- - // 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 password, String direccion, String colonia, ) async { - final url = Uri.parse('$_baseUrl/api/usuarios/register'); - final response = await http.post( - url, + 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) { - final Map jsonData = json.decode(response.body); - return jsonData['usuario_id']; - } else { - throw Exception('Error al registrar usuario: ${response.body}'); + 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); } - // ---------------------------------------------------------------- - // 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); + final response = await http + .get(Uri.parse('$_baseUrl/api/usuarios/$usuarioId')) + .timeout(_timeout); if (response.statusCode == 200) { - final Map jsonData = json.decode(response.body); - return List>.from(jsonData['rutas']) - .map(RouteInfo.fromJson) - .toList(); + return UsuarioInfo.fromJson(json.decode(response.body)); } - - throw Exception('Error al obtener rutas: ${response.body}'); + _throwError(response); } - // ---------------------------------------------------------------- - // 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'); - + Future agregarDireccion(int usuarioId, String colonia, String direccion) async { final response = await http.post( - url, + 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({ - 'colonia': colonia, - 'direccion': direccion.trim(), + 'password_actual': passwordActual, + 'password_nuevo': passwordNuevo, }), ).timeout(_timeout); - if (response.statusCode != 200) { - throw Exception('Error al guardar la dirección: ${response.body}'); - } + if (response.statusCode != 200) _throwError(response); } - // ---------------------------------------------------------------- - // 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, + 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) { - // No es crítico que falle en el hackathon, solo logueamos 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); + } } diff --git a/backend/hackathon.db b/backend/hackathon.db index d0659f07f8402f621f3b18ca86eaaec2c8c30d86..1d43ababc9a69853eae0753f47bc554a7c8e7b90 100644 GIT binary patch delta 1000 zcmZoTz}Rqrae}m!H|ACIu<+hzm^b+? z&le5`8*xQN#%9mSXSt2I8l4&0#bsp~n<6Jy@RvxY<|bz5D1^91gy<*~Bo-H!=NF~K zXCxM9OwQ-g-F%-B4f_?nZuv4oj}LA^=SypeVC zdAZp!#hH2WVB_MA9nvx@^m6i(6LVCIl2i2%*_nk+>%ox zLvxE$jf0)avRp&c%F2?{OH<9ly*(ogQ$4~<3Q`+OnBW%Z$jz=tSYSjQ3;04A6c~h? zG<6$!9rF^?EF;4Mg8YgDQ_L*5jclo(pd$j+c_2#gb;ixu1xi;@qoO)SaJ zPR-K=I$bv{F*{Y)z`zg@l!ll=X-7_E0h8l)21Q_M;GNtfJ3|3sfeFHb#zGbjuo_^B z0V+zxs)!QLa{*n!#($52{}2B+{tx`G_@D6K+pMT?n15mbI~yxABO@EzV#4+N^Q9xzNi7Zv4RO>pG;%DP nTx359sF#5G{vh+Qt5Jfu0YtL0f=Ct?5XsC8BAJ+&nK?ND$pti0 delta 425 zcmZoTz}Rqrae}lU4+8@O8xX?)+e95>ejWzBE)ibd`wXnyhZ%TYZp^FZVQG|RWS{(& z=L-iTlenTHW3%Vvv)nT$7xM~Dp2Oq2`5~VIqac4411rx>2L8KzDtyOzTX=46R#Z67 z!`Z08%Elnxq^Uhgb`eLTEDI}xVw2|Nc)1xIjS5Vx4C+7*qx5VbhZ(}*lA2u)bWX9N zX+Ua9QNBV@X=YVPes*e}ZUKm|o0gcJs%v0i0#xI#Zt9y@ba-u|LP&m5QED+nS#Dxc zW+F_Pu^JN#gSxqMVo^?hv4SKkD}y+Lwo7JedVXSFqC;v$Vs1fBs$Oz_t|B7~gR-Gx zUZR3~;^eoo;zC?N3t0J|F!2B3|Hl7;{}ulepnvxAPn;k+`H8*)A1i+m1OIdWGyJRh v`}vE2QVIN%i|i)>#j$Jg2Wi13CBw?h$jHhHB3W2KBr`LJWMX1w=HvtbW0rM| diff --git a/backend/main.py b/backend/main.py index 4ba5695..3241c6b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,42 +1,82 @@ """ =============================================================== SISTEMA DE NOTIFICACIÓN PRIVADA DE RECOLECCIÓN DE RESIDUOS - Backend MVP - Hackathon 24h + Backend MVP - Hackathon 24h (v2 — mejorado post-hackathon) =============================================================== Stack: FastAPI + SQLite (SQLAlchemy) + Firebase Admin SDK - + + MEJORAS v2: + - Passwords hasheadas con bcrypt (antes: sha256 plano) + - Migración automática de hashes legacy sha256 → bcrypt + - Endpoint /api/dashboard: estadísticas globales de rutas + - Endpoint /api/rutas/{route_id}/posiciones: historial GPS + - Endpoint /api/rutas/resumen: vista rápida de todos los camiones + - Endpoint /api/estadisticas/colonias: colonias más activas + CÓMO CORRER: - pip install fastapi uvicorn sqlalchemy firebase-admin + pip install fastapi uvicorn sqlalchemy firebase-admin bcrypt uvicorn main:app --reload --port 8000 - - ATAJO DE HACKATHON: - Usamos SQLite para cero configuración. En producción - cambiaría a PostgreSQL solo cambiando DATABASE_URL. =============================================================== """ -from fastapi import FastAPI, HTTPException, Depends +from fastapi import FastAPI, HTTPException, Depends, Query from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, inspect, text from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship from pydantic import BaseModel from typing import Optional, List, Dict from datetime import datetime, timezone, timedelta +import bcrypt +import hashlib import logging # --------------------------------------------------------------- -# CONFIGURACIÓN DE LOGGING -# Útil para ver en consola qué está pasando sin un debugger real +# LOGGING # --------------------------------------------------------------- logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) + +# =============================================================== +# SEGURIDAD DE CONTRASEÑAS — bcrypt +# +# POR QUÉ bcrypt en vez de sha256: +# - sha256 es rápido → fácil de atacar con fuerza bruta +# - bcrypt incluye salt automático → dos hashes del mismo +# password son distintos (evita ataques de rainbow table) +# - El "work factor" (12) hace cada verificación ~250ms, +# tolerable para usuarios, costoso para atacantes. +# +# MIGRACIÓN LEGACY: +# Los usuarios registrados antes de esta versión tienen +# password_hash en sha256. Al hacer login, si el hash +# antiguo coincide, re-hasheamos con bcrypt y guardamos. +# =============================================================== + +def hash_password(password: str) -> str: + """Genera un hash bcrypt del password. Incluye salt automático.""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def _es_hash_legacy(password_hash: str) -> bool: + """Detecta si el hash guardado es sha256 (hex de 64 chars) en vez de bcrypt.""" + return len(password_hash) == 64 and not password_hash.startswith("$2b$") + + +def verify_password(password: str, password_hash: str) -> bool: + if not password_hash: # ← hash vacío o None → fallo seguro + return False + if _es_hash_legacy(password_hash): + legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest() + return legacy_hash == password_hash + try: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + except ValueError: # ← salt inválido por cualquier corrupción + return False + + # --------------------------------------------------------------- -# CONFIGURACIÓN DE BASE DE DATOS (SQLite - Hackathon mode) -# -# DATABASE_URL apunta a un archivo local "hackathon.db". -# check_same_thread=False es NECESARIO para FastAPI porque -# maneja requests en múltiples threads con el mismo engine. +# BASE DE DATOS (SQLite — hackathon mode) # --------------------------------------------------------------- DATABASE_URL = "sqlite:///./hackathon.db" engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) @@ -45,88 +85,69 @@ Base = declarative_base() # =============================================================== -# MODELOS DE BASE DE DATOS (SQLAlchemy ORM) +# MODELOS ORM # =============================================================== class Usuario(Base): - """ - Tabla de usuarios del sistema. - - fcm_token: Token de Firebase Cloud Messaging. Cada dispositivo - móvil genera uno único. Es lo que necesitamos para enviar - notificaciones push. Se guarda aquí al hacer login en la app. - - ATAJO: No hay autenticación real (JWT, OAuth). Para el hackathon - el usuario_id es suficiente. En producción: agrega auth. - """ __tablename__ = "usuarios" - id = Column(Integer, primary_key=True, index=True) - nombre = Column(String, nullable=False) - email = Column(String, nullable=False, unique=True, index=True) - # Token FCM que Flutter registrará al iniciar la app - fcm_token = Column(String, nullable=True) + id = Column(Integer, primary_key=True, index=True) + nombre = Column(String, nullable=False) + email = Column(String, nullable=False, unique=True, index=True) + password_hash = Column(String, nullable=False) + fcm_token = Column(String, nullable=True) - # Relación 1-a-muchos con Domicilio (un usuario puede tener varias direcciones) direcciones = relationship("Domicilio", back_populates="usuario", cascade="all, delete-orphan") class Domicilio(Base): - """ - Tabla de domicilios asociados a cada usuario. - - colonia: Nombre de la colonia, es la clave para buscar - en nuestros datos mockeados y obtener el route_id correspondiente. - - PRIVACIDAD POR DISEÑO: El route_id se guarda internamente - pero NUNCA se expone en los endpoints públicos. El usuario - solo ve su ETA, no la ruta completa del camión. - """ __tablename__ = "domicilios" - id = Column(Integer, primary_key=True, index=True) + id = Column(Integer, primary_key=True, index=True) usuario_id = Column(Integer, ForeignKey("usuarios.id")) - colonia = Column(String, nullable=False) - direccion = Column(String, nullable=False) - # route_id es dato interno - no lo devolvemos al cliente - route_id = Column(String, nullable=False) + colonia = Column(String, nullable=False) + direccion = Column(String, nullable=False) + route_id = Column(String, nullable=False) # Interno, no se expone al cliente usuario = relationship("Usuario", back_populates="direcciones") -# Asegura que la DB local tenga las columnas necesarias cuando se actualiza el esquema. +# --------------------------------------------------------------- +# MIGRACIÓN AUTOMÁTICA DE ESQUEMA +# Añade columnas nuevas si la DB existe desde una versión anterior. +# --------------------------------------------------------------- inspector = inspect(engine) if inspector.has_table("usuarios"): - columnas_usuario = [col["name"] for col in inspector.get_columns("usuarios")] - if "email" not in columnas_usuario: - with engine.connect() as conn: + cols = [c["name"] for c in inspector.get_columns("usuarios")] + with engine.connect() as conn: + if "email" not in cols: conn.execute(text("ALTER TABLE usuarios ADD COLUMN email TEXT")) + if "password_hash" not in cols: + conn.execute(text("ALTER TABLE usuarios ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''")) + conn.commit() if inspector.has_table("domicilios"): - columnas_domicilio = [col["name"] for col in inspector.get_columns("domicilios")] - if "direccion" not in columnas_domicilio: - with engine.connect() as conn: + cols = [c["name"] for c in inspector.get_columns("domicilios")] + with engine.connect() as conn: + if "direccion" not in cols: conn.execute(text("ALTER TABLE domicilios ADD COLUMN direccion TEXT NOT NULL DEFAULT ''")) + conn.commit() -# Crea las tablas nuevas en hackathon.db si no existen. Base.metadata.create_all(bind=engine) # =============================================================== -# DATOS MOCKEADOS EN MEMORIA -# -# ATAJO DE HACKATHON: Evitamos una tabla extra de "Rutas" y -# "Horarios" usando simples diccionarios. Esto nos ahorra 2h de -# desarrollo. En producción, estos datos vendrían de la DB. +# DATOS MOCKEADOS # =============================================================== -# Rutas disponibles y sus posiciones GPS para monitoreo de avance. -ROUTE_DATA: List[Dict[str, object]] = [ +# =============================================================== +# PEGA ESTO EN main.py REEMPLAZANDO DESDE "ROUTE_DATA" HASTA +# "ROUTE_STATE" (inclusive el bloque de inicialización de ROUTE_STATE) +# =============================================================== + +ROUTE_DATA = [ { - "route_id": "RUTA-01", - "name": "Zona Centro - Las Arboledas", - "truck_id": 101, - "status": "EN_RUTA", + "route_id": "RUTA-01", "name": "Zona Centro - Las Arboledas", "truck_id": 101, "status": "EN_RUTA", "positions": [ {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"}, {"positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"}, @@ -139,10 +160,7 @@ ROUTE_DATA: List[Dict[str, object]] = [ ], }, { - "route_id": "RUTA-02", - "name": "Sector Norte - Av. Tecnológico", - "truck_id": 102, - "status": "EN_RUTA", + "route_id": "RUTA-02", "name": "Sector Norte - Av. Tecnológico", "truck_id": 102, "status": "EN_RUTA", "positions": [ {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z"}, {"positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z"}, @@ -154,80 +172,413 @@ ROUTE_DATA: List[Dict[str, object]] = [ {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z"}, ], }, + { + "route_id": "RUTA-03", "name": "Sector Poniente - San Juanico", "truck_id": 103, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"}, + {"positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z"}, + {"positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z"}, + {"positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z"}, + {"positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z"}, + ], + }, + { + "route_id": "RUTA-04", "name": "Oriente - Los Olivos", "truck_id": 104, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z"}, + {"positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z"}, + {"positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z"}, + ], + }, + { + "route_id": "RUTA-05", "name": "Sector Sur - Rancho Seco", "truck_id": 105, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z"}, + {"positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z"}, + {"positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"}, + {"positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z"}, + {"positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z"}, + ], + }, + { + "route_id": "RUTA-06", "name": "Norte Extremo - Rumbos de Roque", "truck_id": 106, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z"}, + {"positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z"}, + {"positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z"}, + {"positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z"}, + {"positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z"}, + ], + }, + { + "route_id": "RUTA-07", "name": "Nororiente - Ciudad Industrial", "truck_id": 107, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z"}, + {"positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z"}, + {"positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z"}, + {"positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z"}, + {"positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z"}, + {"positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z"}, + ], + }, + { + "route_id": "RUTA-08", "name": "Suroriente - Universidad Latina", "truck_id": 108, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z"}, + {"positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z"}, + {"positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z"}, + {"positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z"}, + {"positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z"}, + {"positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z"}, + {"positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, + { + "route_id": "RUTA-09", "name": "Poniente - Hospital General", "truck_id": 109, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z"}, + {"positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z"}, + {"positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z"}, + {"positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z"}, + {"positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z"}, + ], + }, + { + "route_id": "RUTA-10", "name": "Eje Juan Pablo II - Sede UG Sur", "truck_id": 110, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z"}, + {"positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z"}, + {"positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z"}, + {"positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z"}, + {"positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z"}, + {"positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z"}, + {"positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z"}, + ], + }, + { + "route_id": "RUTA-11", "name": "Zona de Oro - Torres Landa", "truck_id": 111, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z"}, + {"positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z"}, + {"positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z"}, + {"positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z"}, + {"positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z"}, + {"positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z"}, + {"positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z"}, + ], + }, + { + "route_id": "RUTA-12", "name": "Nororiente - Las Insurgentes", "truck_id": 112, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z"}, + {"positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z"}, + {"positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z"}, + {"positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z"}, + {"positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z"}, + {"positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z"}, + {"positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z"}, + ], + }, + { + "route_id": "RUTA-13", "name": "Sector Norte - Trojes e Irrigación", "truck_id": 113, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z"}, + {"positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z"}, + {"positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z"}, + {"positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z"}, + {"positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"}, + {"positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, + { + "route_id": "RUTA-14", "name": "Sur Poniente - La Toscana", "truck_id": 114, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z"}, + {"positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z"}, + {"positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z"}, + {"positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z"}, + {"positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z"}, + {"positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z"}, + {"positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z"}, + ], + }, + { + "route_id": "RUTA-15", "name": "Norponiente - Camino a San José de Celaya", "truck_id": 115, "status": "EN_RUTA", + "positions": [ + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z"}, + {"positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z"}, + {"positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z"}, + {"positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z"}, + {"positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z"}, + {"positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z"}, + {"positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z"}, + ], + }, ] -ROUTAS_POR_ID: Dict[str, Dict[str, object]] = {route["route_id"]: route for route in ROUTE_DATA} +ROUTAS_POR_ID = {r["route_id"]: r for r in ROUTE_DATA} -HORARIOS_POR_RUTA: Dict[str, Dict[str, object]] = { +HORARIOS_POR_RUTA = { "RUTA-01": {"eta_texto": "Llega en aproximadamente 15 minutos", "eta_minutos": 15}, "RUTA-02": {"eta_texto": "Llega en aproximadamente 30 minutos", "eta_minutos": 30}, "RUTA-03": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40}, "RUTA-04": {"eta_texto": "Llega en aproximadamente 60 minutos", "eta_minutos": 60}, "RUTA-05": {"eta_texto": "Llega en aproximadamente 75 minutos", "eta_minutos": 75}, + "RUTA-06": {"eta_texto": "Llega en aproximadamente 50 minutos", "eta_minutos": 50}, + "RUTA-07": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45}, + "RUTA-08": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55}, + "RUTA-09": {"eta_texto": "Llega en aproximadamente 20 minutos", "eta_minutos": 20}, + "RUTA-10": {"eta_texto": "Llega en aproximadamente 65 minutos", "eta_minutos": 65}, + "RUTA-11": {"eta_texto": "Llega en aproximadamente 25 minutos", "eta_minutos": 25}, "RUTA-12": {"eta_texto": "Llega en aproximadamente 35 minutos", "eta_minutos": 35}, "RUTA-13": {"eta_texto": "Llega en aproximadamente 40 minutos", "eta_minutos": 40}, + "RUTA-14": {"eta_texto": "Llega en aproximadamente 45 minutos", "eta_minutos": 45}, + "RUTA-15": {"eta_texto": "Llega en aproximadamente 55 minutos", "eta_minutos": 55}, } -HORARIOS_POR_COLONIA: List[Dict[str, str]] = [ - {"colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)"}, - {"colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)"}, - {"colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)"}, - {"colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)"}, - {"colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)"}, - {"colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)"}, +HORARIOS_POR_COLONIA = [ + {"colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)"}, + {"colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)"}, + {"colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)"}, + {"colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)"}, + {"colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)"}, + {"colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)"}, {"colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)"}, ] -COLONIAS_A_RUTAS: Dict[str, str] = { - item["colonia"]: item["routeId"] for item in HORARIOS_POR_COLONIA -} +COLONIAS_A_RUTAS = {item["colonia"]: item["routeId"] for item in HORARIOS_POR_COLONIA} TRIGGER_NOTIFICATIONS = { "ROUTE_START": { "position_id": 2, "title": "¡Ruta Iniciada!", - "body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos." + "body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos.", }, "TRUCK_PROXIMITY": { "position_id": 4, "title": "Camión Cercano", - "body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera." + "body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.", }, "ROUTE_COMPLETED": { "position_id": 8, "title": "Servicio Finalizado", - "body": "El camión de tu sector ha concluido su jornada de recolección diaria." + "body": "El camión de tu sector ha concluido su jornada de recolección diaria.", }, "GPS_OUTAGE": { "title": "Alerta GPS", - "body": "El GPS del camión dejó de reportar su ubicación. Estamos investigando la ruta." + "body": "El GPS del camión dejó de reportar su ubicación. Estamos investigando la ruta.", }, } -# Estado de avance de cada ruta en memoria. -ROUTE_STATE: Dict[str, Dict[str, object]] = {} -for route in ROUTE_DATA: - posiciones = route.get("positions", []) - ROUTE_STATE[route["route_id"]] = { - "last_position_id": posiciones[0]["positionId"] if posiciones else 0, +TIPOS_EVENTO_VALIDOS = ["en_camino", "llegando", "completado", "retrasado"] +INFO_ARTICULOS = [ + { + "id": "separacion-basica", + "categoria": "Separación", + "emoji": "♻️", + "titulo": "Cómo separar correctamente tu basura", + "resumen": "La separación correcta es el primer paso para reciclar y reducir el impacto ambiental.", + "contenido": [ + { + "subtitulo": "Residuos Orgánicos 🟤", + "texto": "Restos de comida, cáscaras de frutas y verduras, posos de café, bolsas de té, restos de jardín. Van en bolsa oscura o café. Se convierten en composta.", + }, + { + "subtitulo": "Residuos Inorgánicos Reciclables 🟡", + "texto": "Plásticos (botellas PET, envases), papel y cartón limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente o amarilla. Deben estar limpios y secos.", + }, + { + "subtitulo": "Residuos No Reciclables 🔴", + "texto": "Papel higiénico usado, pañales, colillas de cigarro, envolturas metalizadas (como papas). Van en bolsa negra. No tienen valor de reciclaje.", + }, + { + "subtitulo": "Residuos Especiales ⚠️", + "texto": "Pilas, medicamentos caducados, electrónicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas y electrónicos a puntos de acopio en supermercados.", + }, + ], + "consejo_rapido": "Regla fácil: si vino de la naturaleza y se pudre → orgánico. Si es artificial y limpio → reciclable.", + }, + { + "id": "cuando-sacar", + "categoria": "Horarios", + "emoji": "⏰", + "titulo": "¿Cuándo sacar tu basura?", + "resumen": "Sacar la basura en el momento correcto evita plagas, malos olores y que el camión se la pierda.", + "contenido": [ + { + "subtitulo": "El momento ideal", + "texto": "Saca tu basura cuando recibas la alerta de 'Camión Cercano' en la app. Eso significa que el camión está a menos de 15 minutos de tu domicilio.", + }, + { + "subtitulo": "¿Por qué no sacarla de noche?", + "texto": "Las bolsas en la acera de noche atraen perros, gatos y fauna nocturna que las rompen y dispersan los residuos. Además el plástico se deteriora con la humedad nocturna.", + }, + { + "subtitulo": "¿Y si me lo pierdo?", + "texto": "Si el camión ya pasó, guarda tu basura hasta el siguiente día. Nunca dejes bolsas en la vía pública fuera del horario de recolección: es una multa en muchos municipios.", + }, + { + "subtitulo": "Días festivos", + "texto": "En días festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de la app para recibir alertas de retraso o cambio de horario.", + }, + ], + "consejo_rapido": "Espera la alerta de la app antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.", + }, + { + "id": "plasticos-guia", + "categoria": "Reciclaje", + "emoji": "🧴", + "titulo": "Guía de plásticos: cuáles sí y cuáles no", + "resumen": "No todos los plásticos son iguales. Aprende a leer el número en el triángulo de reciclaje.", + "contenido": [ + { + "subtitulo": "✅ Plástico #1 — PET", + "texto": "Botellas de agua, refrescos, aceite. El más reciclado. Aplástalo para ahorrar espacio. Quita la tapa (es diferente material).", + }, + { + "subtitulo": "✅ Plástico #2 — HDPE", + "texto": "Garrafones, botellas de leche, shampú. También muy reciclable. Enjuágalo antes de separarlo.", + }, + { + "subtitulo": "✅ Plástico #5 — PP", + "texto": "Tapas de botellas, envases de yogur, popotes. Sí se recicla pero menos centros lo aceptan.", + }, + { + "subtitulo": "❌ Plásticos #3, #6, #7", + "texto": "PVC (mangueras, tuberías), poliestireno expandido (unicel), policarbonato. Difíciles o imposibles de reciclar. Van a basura no reciclable.", + }, + { + "subtitulo": "❌ Bolsas de plástico", + "texto": "Las bolsas de supermercado no van en el reciclaje de casa: tapan las máquinas clasificadoras. Lleva tus bolsas a centros de acopio específicos en supermercados.", + }, + ], + "consejo_rapido": "Busca el número dentro del triángulo en el fondo del envase. #1 y #2 siempre al reciclaje.", + }, + { + "id": "composta", + "categoria": "Compostaje", + "emoji": "🌱", + "titulo": "Haz composta en casa", + "resumen": "Convierte tus residuos orgánicos en abono natural. Es más fácil de lo que crees.", + "contenido": [ + { + "subtitulo": "¿Qué necesitas?", + "texto": "Un contenedor con tapa (puede ser una cubeta con tapa o una caja de madera), residuos orgánicos, tierra o tierra de hojarasca, y un poco de paciencia.", + }, + { + "subtitulo": "¿Qué puedes compostar?", + "texto": "Cáscaras de frutas y verduras, restos de comida cocida sin carne, posos de café y filtros de papel, cáscaras de huevo (aplástelas), hojas secas, recortes de jardín.", + }, + { + "subtitulo": "¿Qué NO debes compostar?", + "texto": "Carnes, pescados, lácteos, aceites (atraen plagas), excrementos de mascotas (patógenos), plásticos ni metales.", + }, + { + "subtitulo": "El proceso", + "texto": "Alterna capas de residuos orgánicos húmedos con capas de material seco (tierra, hojas). Voltea la mezcla cada semana. En 2-3 meses tendrás composta lista para tus plantas.", + }, + ], + "consejo_rapido": "La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega más material seco y voltéala.", + }, + { + "id": "residuos-peligrosos", + "categoria": "Residuos Especiales", + "emoji": "⚠️", + "titulo": "Residuos peligrosos: cómo deshacerte de ellos", + "resumen": "Pilas, medicamentos y electrónicos requieren un manejo especial para no contaminar el suelo y el agua.", + "contenido": [ + { + "subtitulo": "Pilas y baterías", + "texto": "Una sola pila AA puede contaminar 600,000 litros de agua. Guárdalas en una bolsa o caja y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO. Nunca al drenaje ni al fuego.", + }, + { + "subtitulo": "Medicamentos caducados", + "texto": "No los tires al drenaje ni a la basura regular. Farmacias como Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos. El municipio también hace jornadas de recolección.", + }, + { + "subtitulo": "Electrónicos (RAEE)", + "texto": "Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electrónicos (Best Buy, Liverpool) o espera las jornadas municipales de recolección.", + }, + { + "subtitulo": "Aceite de cocina", + "texto": "Un litro de aceite contamina hasta 1,000 litros de agua potable. Enfríalo, viértelo en una botella PET con tapa y lleva a centros de acopio o úsalo para hacer jabón casero.", + }, + ], + "consejo_rapido": "Guarda una caja en casa exclusiva para residuos peligrosos. Cuando esté llena, busca el punto de acopio más cercano.", + }, + { + "id": "impacto-ambiental", + "categoria": "Medio Ambiente", + "emoji": "🌍", + "titulo": "El impacto real de reciclar", + "resumen": "Números concretos para entender por qué vale la pena separar tu basura cada día.", + "contenido": [ + { + "subtitulo": "Papel y cartón", + "texto": "Reciclar 1 tonelada de papel salva 17 árboles, ahorra 26,000 litros de agua y evita 4,000 kWh de energía. Una familia promedio genera ~500 kg de papel al año.", + }, + { + "subtitulo": "Aluminio", + "texto": "Reciclar una lata de aluminio ahorra la energía suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces sin perder calidad.", + }, + { + "subtitulo": "Vidrio", + "texto": "El vidrio tarda más de 4,000 años en degradarse. Reciclarlo reduce en 20% las emisiones de CO₂ de su producción. Una botella puede reciclarse indefinidamente.", + }, + { + "subtitulo": "Plástico PET", + "texto": "5 botellas PET recicladas generan fibra suficiente para una camiseta de poliéster. México recicla menos del 20% del PET que consume — hay mucho potencial de mejora.", + }, + { + "subtitulo": "Residuos en México", + "texto": "México genera ~120,000 toneladas de basura al día. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podría triplicarse.", + }, + ], + "consejo_rapido": "Cada lata de aluminio que reciclas ahorra energía equivalente a medio litro de gasolina. Sí importa.", + }, +] +ROUTE_STATE = {} +for _route in ROUTE_DATA: + _pos = _route.get("positions", []) + ROUTE_STATE[_route["route_id"]] = { + "last_position_id": _pos[0]["positionId"] if _pos else 0, "last_timestamp": datetime.now(timezone.utc), "gps_ok": True, "gps_alert_sent": False, - "triggers_sent": {trigger_key: False for trigger_key in TRIGGER_NOTIFICATIONS if trigger_key != "GPS_OUTAGE"}, + "triggers_sent": {k: False for k in TRIGGER_NOTIFICATIONS if k != "GPS_OUTAGE"}, + "error_message": None, } -# Tipos de evento válidos para el simulador -TIPOS_EVENTO_VALIDOS = ["en_camino", "llegando", "completado", "retrasado"] - # =============================================================== -# CONFIGURACIÓN FIREBASE ADMIN SDK -# -# CÓMO ACTIVARLO: -# 1. Ve a Firebase Console -> Configuración del Proyecto -> -# Cuentas de Servicio -> Generar nueva clave privada -# 2. Guarda el JSON como "firebase-credentials.json" junto a main.py -# 3. Descomenta el bloque de inicialización de abajo +# FIREBASE ADMIN SDK # =============================================================== import firebase_admin from firebase_admin import credentials, messaging @@ -239,20 +590,18 @@ try: except Exception as e: logger.error(f"❌ Error inicializando Firebase: {e}") - -FIREBASE_ACTIVO = True # Cambia a True al desbloquear Firebase +FIREBASE_ACTIVO = True # =============================================================== -# INICIALIZACIÓN DE FASTAPI +# FASTAPI APP # =============================================================== app = FastAPI( - title="Sistema de Notificación de Residuos - MVP Hackathon", + title="Sistema de Notificación de Residuos — v2", description="API privada para notificaciones de recolección de basura", - version="0.1.0-hackathon" + version="0.2.0", ) -# CORS abierto para desarrollo. En producción: restringe origins. app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -261,13 +610,6 @@ app.add_middleware( ) -# --------------------------------------------------------------- -# DEPENDENCIA: Sesión de Base de Datos -# -# FastAPI usa Dependency Injection. Esta función provee una sesión -# de DB a cada endpoint y garantiza que se cierre al terminar, -# sin importar si hubo error. Es el patrón estándar de FastAPI+SQLAlchemy. -# --------------------------------------------------------------- def get_db(): db = SessionLocal() try: @@ -277,13 +619,10 @@ def get_db(): # =============================================================== -# SCHEMAS PYDANTIC (Validación de request/response) -# Pydantic valida automáticamente los tipos. FastAPI los usa para -# generar la documentación en /docs sin esfuerzo adicional. +# SCHEMAS PYDANTIC # =============================================================== class ETAResponse(BaseModel): - """Respuesta del endpoint de ETA. Sin route_id por privacidad.""" usuario_id: int colonia: str ruta_nombre: str @@ -291,51 +630,52 @@ class ETAResponse(BaseModel): gps_ok: bool eta_texto: str eta_minutos: int - mensaje_preventivo: str # Ej: "No saques tu basura aún" + mensaje_preventivo: str + error_message: Optional[str] = None class UsuarioRegisterRequest(BaseModel): nombre: str email: str + password: str colonia: str direccion: str +class UsuarioLoginRequest(BaseModel): + email: str + password: str + + class DireccionRequest(BaseModel): colonia: str direccion: str -class UsuarioLoginRequest(BaseModel): - email: str - - class DomicilioResponse(BaseModel): colonia: str direccion: str +class DomicilioAdminResponse(BaseModel): + colonia: str + direccion: str + route_id: str + ruta_nombre: str + + class UsuarioResponse(BaseModel): usuario_id: int nombre: str - email: str + email: Optional[str] = None # ← igual aquí direcciones: List[DomicilioResponse] -class RoutePositionUpdateRequest(BaseModel): - position_id: int - lat: float - lng: float - timestamp: str - - -class RouteStatusResponse(BaseModel): - route_id: str - name: str - status: str - last_position_id: int - last_timestamp: str - gps_ok: bool +class UsuarioAdminResponse(BaseModel): + usuario_id: int + nombre: str + email: Optional[str] = None # ← cambia str por Optional[str] + direcciones: List[DomicilioAdminResponse] class RegisterResponse(BaseModel): @@ -345,25 +685,91 @@ class RegisterResponse(BaseModel): class LoginResponse(BaseModel): usuario_id: int + nombre: str mensaje: str -class SimularEventoRequest(BaseModel): - """ - Payload para simular un evento de ruta. - route_id: ID interno de la ruta (RUTA-01, RUTA-02...) - tipo_evento: Tipo de evento a simular - """ +class RouteStatusResponse(BaseModel): route_id: str - tipo_evento: str # "en_camino" | "llegando" | "completado" | "retrasado" + name: str + status: str + last_position_id: int + last_timestamp: str + gps_ok: bool + error_message: Optional[str] = None + + +class RoutePositionUpdateRequest(BaseModel): + position_id: int + lat: float + lng: float + timestamp: str + + +class ActualizarPasswordRequest(BaseModel): + password_actual: str # Requerimos la contraseña actual para cambiarla + password_nuevo: str + + +class ActualizarTokenRequest(BaseModel): + fcm_token: str + + +class SimularEventoRequest(BaseModel): + route_id: str + tipo_evento: str class SimularEventoResponse(BaseModel): - """Respuesta del simulador de eventos.""" usuarios_notificados: int route_id: str tipo_evento: str - detalle: list[str] # Log de qué pasó con cada usuario + detalle: list[str] + + +# --------------------------------------------------------------- +# NUEVOS SCHEMAS PARA VISUALIZACIÓN +# --------------------------------------------------------------- + +class PosicionGPS(BaseModel): + position_id: int + lat: float + lng: float + speed: int + timestamp: str + es_actual: bool # True si esta es la posición donde está el camión ahora + + +class RutaDetalleResponse(BaseModel): + route_id: str + name: str + status: str + truck_id: int + posicion_actual: int + total_posiciones: int + porcentaje_completado: float + eta_minutos: int + gps_ok: bool + usuarios_en_ruta: int # Cuántos usuarios están en esta ruta + + +class DashboardResponse(BaseModel): + total_rutas: int + rutas_en_progreso: int + rutas_completadas: int + total_usuarios: int + usuarios_con_token: int # Cuántos pueden recibir push notifications + cobertura_notificaciones: float # % de usuarios con FCM token + rutas: List[RutaDetalleResponse] + + +class ColoniaEstadisticaResponse(BaseModel): + colonia: str + route_id: str + ruta_nombre: str + horario: str + total_usuarios: int + usuarios_con_notificaciones: int # =============================================================== @@ -371,13 +777,6 @@ class SimularEventoResponse(BaseModel): # =============================================================== def generar_mensaje_preventivo(eta_minutos: int) -> str: - """ - Genera un mensaje contextual basado en el tiempo de llegada. - - La MENSAJERÍA PREVENTIVA es el core del sistema: - evitamos que el usuario saque la basura demasiado pronto - o demasiado tarde, mejorando la experiencia y la higiene. - """ if eta_minutos <= 5: return "🚛 ¡El camión está muy cerca! Saca tu basura AHORA." elif eta_minutos <= 15: @@ -389,21 +788,9 @@ def generar_mensaje_preventivo(eta_minutos: int) -> str: def enviar_notificacion_firebase(fcm_token: str, titulo: str, cuerpo: str) -> bool: - """ - Envía una notificación push via Firebase Cloud Messaging. - - ATAJO: La función existe y está lista, pero si FIREBASE_ACTIVO=False - solo simula el envío en los logs. Esto nos permite desarrollar - el flujo completo sin credenciales reales. - - Retorna True si el envío fue exitoso (o simulado), False si falló. - """ if not FIREBASE_ACTIVO: - # Modo simulación: log del intento sin llamar a Firebase - logger.info(f"[SIMULADO] Push -> Token: {fcm_token[:20]}... | {titulo}: {cuerpo}") + logger.info(f"[SIMULADO] Push → Token: {fcm_token[:20]}... | {titulo}: {cuerpo}") return True - - # --- CÓDIGO REAL DE FIREBASE (desbloquear cuando FIREBASE_ACTIVO=True) --- try: message = messaging.Message( notification=messaging.Notification(title=titulo, body=cuerpo), @@ -413,103 +800,85 @@ def enviar_notificacion_firebase(fcm_token: str, titulo: str, cuerpo: str) -> bo logger.info(f"✅ Notificación enviada: {response}") return True except Exception as e: - logger.error(f"❌ Error enviando push a {fcm_token[:20]}...: {e}") + logger.error(f"❌ Error enviando push: {e}") return False -def _obtener_ruta_por_colonia(colonia: str) -> Optional[Dict[str, object]]: - route_id = COLONIAS_A_RUTAS.get(colonia) - if not route_id: - return None - return ROUTAS_POR_ID.get(route_id) - - -def _calcular_eta_por_ruta(route_id: str) -> Dict[str, object]: - """Calcula un ETA con base en la posición actual de la ruta.""" +def _calcular_eta_por_ruta(route_id: str) -> Dict: estado = ROUTE_STATE.get(route_id) ruta = ROUTAS_POR_ID.get(route_id, {}) horario = HORARIOS_POR_RUTA.get(route_id) if not horario: - return { - "eta_texto": "No hay horario disponible.", - "eta_minutos": 60, - } + return {"eta_texto": "Horario no disponible para esta zona.", "eta_minutos": 60} if estado and ruta.get("positions"): + if ruta.get("status") == "DETENIDA": + return {"eta_texto": "Ruta detenida", "eta_minutos": 0} posiciones = ruta["positions"] ultimo_id = estado.get("last_position_id", 1) if ultimo_id >= len(posiciones): - return { - "eta_texto": "El servicio ya pasó por tu zona.", - "eta_minutos": 0, - } + return {"eta_texto": "El servicio ya pasó por tu zona.", "eta_minutos": 0} pasos_restantes = max(0, len(posiciones) - ultimo_id) eta = pasos_restantes * 10 - return { - "eta_texto": f"Llega en aproximadamente {eta} minutos", - "eta_minutos": eta, - } - - return { - "eta_texto": horario["eta_texto"], - "eta_minutos": horario["eta_minutos"], - } + return {"eta_texto": f"Llega en aproximadamente {eta} minutos", "eta_minutos": eta} + return {"eta_texto": horario["eta_texto"], "eta_minutos": horario["eta_minutos"]} def _verificar_gps_outage(route_id: str, db: Session) -> None: - """Verifica si una ruta dejó de reportar GPS y notifica a los usuarios una sola vez.""" estado = ROUTE_STATE.get(route_id) if not estado: return - - ultimo_timestamp = estado.get("last_timestamp", datetime.now(timezone.utc)) - gps_ok = (datetime.now(timezone.utc) - ultimo_timestamp) < timedelta(minutes=10) + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) if not gps_ok and not estado.get("gps_alert_sent", False): - logger.warning(f"Alerta GPS outage para {route_id}. Enviando notificaciones.") + logger.warning(f"Alerta GPS outage para {route_id}") _notificar_ruta(db, route_id, "GPS_OUTAGE") estado["gps_alert_sent"] = True - -def _obtener_estado_ruta(route_id: str, db: Optional[Session] = None) -> Dict[str, object]: +def _obtener_estado_ruta(route_id: str, db: Optional[Session] = None) -> Dict: ruta = ROUTAS_POR_ID.get(route_id) estado = ROUTE_STATE.get(route_id, {}) + if not ruta: - raise ValueError("Ruta no encontrada") - - ultimo_timestamp = estado.get("last_timestamp", datetime.now(timezone.utc)) - gps_ok = (datetime.now(timezone.utc) - ultimo_timestamp) < timedelta(minutes=10) - + # Ruta en DB pero sin datos en memoria — retornar estado genérico + return { + "route_id": route_id, + "name": f"Ruta {route_id}", + "status": "SIN_DATOS", + "last_position_id": 0, + "last_timestamp": datetime.now(timezone.utc).isoformat(), + "gps_ok": False, + "error_message": "Ruta sin datos de seguimiento disponibles.", + } + + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) if db is not None and not gps_ok: _verificar_gps_outage(route_id, db) - return { "route_id": route_id, "name": ruta.get("name", "Ruta desconocida"), "status": ruta.get("status", "DESCONOCIDA"), "last_position_id": estado.get("last_position_id", 0), - "last_timestamp": ultimo_timestamp.isoformat(), + "last_timestamp": ultimo.isoformat(), "gps_ok": gps_ok, + "error_message": estado.get("error_message"), } def _procesar_trigger_posicion(route_id: str, position_id: int, db: Session) -> list[str]: - """Envía notificaciones basadas en el position_id y evita duplicados.""" estado = ROUTE_STATE.get(route_id) if not estado: return [] - mensajes = [] sent_map = estado.setdefault("triggers_sent", {}) - for trigger_key, trigger in TRIGGER_NOTIFICATIONS.items(): if trigger_key == "GPS_OUTAGE": continue - if trigger.get("position_id") == position_id and not sent_map.get(trigger_key, False): mensajes.extend(_notificar_ruta(db, route_id, trigger_key)) sent_map[trigger_key] = True - return mensajes @@ -517,91 +886,53 @@ def _notificar_ruta(db: Session, route_id: str, trigger_key: str) -> list[str]: trigger = TRIGGER_NOTIFICATIONS.get(trigger_key) if not trigger: return [f"Trigger desconocido: {trigger_key}"] - domicilios = db.query(Domicilio).filter(Domicilio.route_id == route_id).all() mensajes = [] - for domicilio in domicilios: usuario = domicilio.usuario if not usuario or not usuario.fcm_token: - mensajes.append(f"Usuario no tiene token o no existe.") + mensajes.append("Usuario sin token.") continue enviado = enviar_notificacion_firebase(usuario.fcm_token, trigger["title"], trigger["body"]) - if enviado: - mensajes.append(f"Notificación enviada a {usuario.nombre} (ID {usuario.id}).") - else: - mensajes.append(f"Fallo al enviar a {usuario.nombre} (ID {usuario.id}).") + mensajes.append( + f"✅ Push a {usuario.nombre}" if enviado else f"❌ Fallo push a {usuario.nombre}" + ) return mensajes +def _obtener_rutas_usuario(usuario_id: int, db: Session) -> List[str]: + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + return list({d.route_id for d in usuario.direcciones}) + + # =============================================================== -# ENDPOINT: SEED DE DATOS DE PRUEBA -# -# Crea usuarios de prueba para poder testear sin un frontend. -# Llama: POST /api/seed -# ATAJO: En producción, eliminar este endpoint. +# ENDPOINTS — USUARIOS # =============================================================== -@app.post("/api/seed", tags=["Utilidades"]) -def seed_datos(db: Session = Depends(get_db)): - """ - Crea usuarios de prueba en la DB para demos rápidas. - Idempotente: si los usuarios ya existen, no hace nada. - """ - # Verifica si ya hay datos para no duplicar - if db.query(Usuario).count() > 0: - return {"mensaje": "Ya hay datos en la DB. No se hizo nada."} - - usuarios_seed = [ - {"nombre": "Ana García", "email": "ana@example.com", "colonia": "Zona Centro", "direccion": "Calle Principal 123", "fcm_token": "token-ana-fake-001"}, - {"nombre": "Carlos López", "email": "carlos@example.com", "colonia": "Col. Hidalgo", "direccion": "Av. Hidalgo 45", "fcm_token": "token-carlos-fake-002"}, - {"nombre": "María Torres", "email": "maria@example.com", "colonia": "Col. Independencia", "direccion": "Calle Luna 12", "fcm_token": "token-maria-fake-003"}, - {"nombre": "Pedro Ruiz", "email": "pedro@example.com", "colonia": "Col. San Juan", "direccion": "Calle Sol 78", "fcm_token": "token-pedro-fake-004"}, - ] - - for u in usuarios_seed: - colonia = u["colonia"] - route_id = COLONIAS_A_RUTAS.get(colonia, "RUTA-01") - - usuario = Usuario(nombre=u["nombre"], email=u["email"], fcm_token=u["fcm_token"]) - db.add(usuario) - db.flush() # flush para obtener el id antes del commit - - domicilio = Domicilio( - usuario_id=usuario.id, - colonia=colonia, - direccion=u["direccion"], - route_id=route_id, - ) - db.add(domicilio) - - db.commit() - logger.info("✅ Seed completado: 4 usuarios creados") - return {"mensaje": "Seed exitoso. Usuarios IDs: 1, 2, 3, 4"} - @app.post("/api/usuarios/register", response_model=RegisterResponse, tags=["Usuarios"]) def registrar_usuario(payload: UsuarioRegisterRequest, db: Session = Depends(get_db)): - existing = db.query(Usuario).filter(Usuario.email == payload.email).first() + existing = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first() if existing: raise HTTPException(status_code=400, detail="El correo ya está registrado.") - + if not payload.password or not payload.password.strip(): + raise HTTPException(status_code=400, detail="La contraseña no puede estar vacía.") route_id = COLONIAS_A_RUTAS.get(payload.colonia) if not route_id: - raise HTTPException(status_code=400, detail="Colonia no válida.") + raise HTTPException(status_code=400, detail=f"Colonia no válida. Opciones: {list(COLONIAS_A_RUTAS.keys())}") - usuario = Usuario(nombre=payload.nombre, email=payload.email.lower().strip(), fcm_token=None) + usuario = Usuario( + nombre=payload.nombre.strip(), + email=payload.email.lower().strip(), + password_hash=hash_password(payload.password), + fcm_token=None, + ) db.add(usuario) db.flush() - - direccion = Domicilio( - usuario_id=usuario.id, - colonia=payload.colonia, - direccion=payload.direccion, - route_id=route_id, - ) - db.add(direccion) + db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id)) db.commit() - + logger.info(f"✅ Usuario registrado: {usuario.email} (ID {usuario.id})") return RegisterResponse(usuario_id=usuario.id, mensaje="Usuario registrado correctamente.") @@ -610,7 +941,18 @@ def login_usuario(payload: UsuarioLoginRequest, db: Session = Depends(get_db)): usuario = db.query(Usuario).filter(Usuario.email == payload.email.lower().strip()).first() if not usuario: raise HTTPException(status_code=404, detail="Usuario no encontrado. Regístrate primero.") - return LoginResponse(usuario_id=usuario.id, mensaje="Login exitoso.") + if not verify_password(payload.password, usuario.password_hash): + raise HTTPException(status_code=401, detail="Contraseña incorrecta.") + + # ── MIGRACIÓN AUTOMÁTICA LEGACY ────────────────────────────── + # Si el hash guardado es sha256 (antiguo), lo re-hasheamos con + # bcrypt ahora que sabemos que el password es correcto. + if _es_hash_legacy(usuario.password_hash): + usuario.password_hash = hash_password(payload.password) + db.commit() + logger.info(f"🔄 Hash migrado sha256→bcrypt para usuario {usuario.id}") + + return LoginResponse(usuario_id=usuario.id, nombre=usuario.nombre, mensaje="Login exitoso.") @app.get("/api/usuarios/{usuario_id}", response_model=UsuarioResponse, tags=["Usuarios"]) @@ -618,57 +960,137 @@ def obtener_usuario(usuario_id: int, db: Session = Depends(get_db)): usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() if not usuario: raise HTTPException(status_code=404, detail="Usuario no encontrado.") - - direcciones = [ - DomicilioResponse(colonia=d.colonia, direccion=d.direccion) - for d in usuario.direcciones - ] - return UsuarioResponse( usuario_id=usuario.id, nombre=usuario.nombre, email=usuario.email, - direcciones=direcciones, + direcciones=[DomicilioResponse(colonia=d.colonia, direccion=d.direccion) for d in usuario.direcciones], ) +@app.get("/api/usuarios", response_model=List[UsuarioAdminResponse], tags=["Usuarios"]) +def listar_usuarios(db: Session = Depends(get_db)): + usuarios = db.query(Usuario).all() + return [ + UsuarioAdminResponse( + usuario_id=u.id, + nombre=u.nombre, + email=u.email, + direcciones=[ + DomicilioAdminResponse( + colonia=d.colonia, + direccion=d.direccion, + route_id=d.route_id, + ruta_nombre=ROUTAS_POR_ID.get(d.route_id, {}).get("name", "Desconocida"), + ) + for d in u.direcciones + ], + ) + for u in usuarios + ] + + @app.post("/api/usuarios/{usuario_id}/direcciones", tags=["Usuarios"]) def agregar_direccion(usuario_id: int, payload: DireccionRequest, db: Session = Depends(get_db)): usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() if not usuario: raise HTTPException(status_code=404, detail="Usuario no encontrado.") - route_id = COLONIAS_A_RUTAS.get(payload.colonia) if not route_id: raise HTTPException(status_code=400, detail="Colonia no válida.") - - direccion = Domicilio( - usuario_id=usuario.id, - colonia=payload.colonia, - direccion=payload.direccion, - route_id=route_id, - ) - db.add(direccion) + db.add(Domicilio(usuario_id=usuario.id, colonia=payload.colonia, direccion=payload.direccion, route_id=route_id)) db.commit() - return {"mensaje": "Dirección agregada correctamente."} +@app.put("/api/usuarios/{usuario_id}/password", tags=["Usuarios"]) +def actualizar_password(usuario_id: int, payload: ActualizarPasswordRequest, db: Session = Depends(get_db)): + """ + Actualiza la contraseña de un usuario. + Requiere la contraseña actual para confirmar identidad. + """ + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + if not verify_password(payload.password_actual, usuario.password_hash): + raise HTTPException(status_code=401, detail="La contraseña actual es incorrecta.") + if not payload.password_nuevo or not payload.password_nuevo.strip(): + raise HTTPException(status_code=400, detail="La nueva contraseña no puede estar vacía.") + if len(payload.password_nuevo) < 6: + raise HTTPException(status_code=400, detail="La nueva contraseña debe tener al menos 6 caracteres.") + usuario.password_hash = hash_password(payload.password_nuevo) + db.commit() + logger.info(f"🔑 Contraseña actualizada para usuario {usuario_id}") + return {"mensaje": "Contraseña actualizada correctamente."} + + +@app.put("/api/usuarios/{usuario_id}/fcm-token", tags=["Utilidades"]) +def actualizar_fcm_token(usuario_id: int, payload: ActualizarTokenRequest, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail="Usuario no encontrado.") + usuario.fcm_token = payload.fcm_token + db.commit() + return {"mensaje": f"Token actualizado para usuario {usuario_id}"} + + +# =============================================================== +# ENDPOINTS — RUTAS Y ETA +# =============================================================== + +@app.get("/api/eta/{usuario_id}", response_model=ETAResponse, tags=["Core"]) +def obtener_eta(usuario_id: int, db: Session = Depends(get_db)): + usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() + if not usuario: + raise HTTPException(status_code=404, detail=f"Usuario {usuario_id} no encontrado.") + if not usuario.direcciones: + raise HTTPException(status_code=404, detail="El usuario no tiene direcciones registradas.") + + direccion = db.query(Domicilio).filter(Domicilio.usuario_id == usuario_id).order_by(Domicilio.id.desc()).first() + route_id = direccion.route_id + ruta = ROUTAS_POR_ID.get(route_id, {}) + estado_ruta = _obtener_estado_ruta(route_id, db) + calculo = _calcular_eta_por_ruta(route_id) + + return ETAResponse( + usuario_id=usuario_id, + colonia=direccion.colonia, + ruta_nombre=ruta.get("name", "Ruta desconocida"), + ruta_status=estado_ruta["status"], + gps_ok=estado_ruta["gps_ok"], # ← agregar esta línea + eta_texto=calculo["eta_texto"], + eta_minutos=calculo["eta_minutos"], + mensaje_preventivo=generar_mensaje_preventivo(calculo["eta_minutos"]), +) + + @app.get("/api/rutas", tags=["Rutas"]) -def listar_rutas(db: Session = Depends(get_db)): - return {"rutas": [_obtener_estado_ruta(route["route_id"], db) for route in ROUTE_DATA]} +def listar_rutas(usuario_id: int = Query(...), db: Session = Depends(get_db)): + rutas_usuario = _obtener_rutas_usuario(usuario_id, db) + return {"rutas": [_obtener_estado_ruta(r, db) for r in rutas_usuario]} + + +@app.get("/api/rutas/{route_id}/estado", response_model=RouteStatusResponse, tags=["Rutas"]) +def estado_ruta(route_id: str, db: Session = Depends(get_db)): + try: + return RouteStatusResponse(**_obtener_estado_ruta(route_id, db)) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) @app.post("/api/rutas/{route_id}/avanzar", response_model=RouteStatusResponse, tags=["Rutas"]) -def avanzar_ruta(route_id: str, db: Session = Depends(get_db)): +def avanzar_ruta(route_id: str, usuario_id: int = Query(...), db: Session = Depends(get_db)): if route_id not in ROUTAS_POR_ID: raise HTTPException(status_code=404, detail="Ruta no encontrada.") + rutas_usuario = _obtener_rutas_usuario(usuario_id, db) + if route_id not in rutas_usuario: + raise HTTPException(status_code=403, detail="No tienes permiso para avanzar esta ruta.") ruta = ROUTAS_POR_ID[route_id] estado = ROUTE_STATE.get(route_id) posiciones = ruta.get("positions", []) if not estado or not posiciones: - raise HTTPException(status_code=500, detail="Estado interno de la ruta no disponible.") + raise HTTPException(status_code=500, detail="Estado de ruta no disponible.") actual = estado.get("last_position_id", 0) if actual < len(posiciones): @@ -677,259 +1099,300 @@ def avanzar_ruta(route_id: str, db: Session = Depends(get_db)): estado["last_timestamp"] = datetime.now(timezone.utc) estado["gps_ok"] = True estado["gps_alert_sent"] = False - mensaje_log = _procesar_trigger_posicion(route_id, siguiente, db) - if siguiente >= len(posiciones): - ruta["status"] = "COMPLETADO" - else: - ruta["status"] = "EN_RUTA" - else: - mensaje_log = [] - - if mensaje_log: - logger.info(f"Notificaciones disparadas en {route_id}: {mensaje_log}") + mensajes = _procesar_trigger_posicion(route_id, siguiente, db) + ruta["status"] = "COMPLETADO" if siguiente >= len(posiciones) else "EN_RUTA" + if mensajes: + logger.info(f"Triggers en {route_id}: {mensajes}") return RouteStatusResponse(**_obtener_estado_ruta(route_id, db)) -@app.get("/api/rutas/{route_id}/estado", response_model=RouteStatusResponse, tags=["Rutas"]) -def estado_ruta(route_id: str, db: Session = Depends(get_db)): - try: - estado = _obtener_estado_ruta(route_id, db) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - return RouteStatusResponse(**estado) - - @app.post("/api/rutas/{route_id}/posicion", tags=["Rutas"]) def actualizar_posicion_ruta(route_id: str, payload: RoutePositionUpdateRequest, db: Session = Depends(get_db)): if route_id not in ROUTAS_POR_ID: raise HTTPException(status_code=404, detail="Ruta no encontrada.") - estado = ROUTE_STATE.get(route_id) if not estado: raise HTTPException(status_code=500, detail="Estado de ruta no inicializado.") - try: timestamp = datetime.fromisoformat(payload.timestamp.replace("Z", "+00:00")) except ValueError: raise HTTPException(status_code=400, detail="Timestamp inválido. Usa formato ISO 8601 UTC.") - mensaje_log = [] + mensajes = [] if payload.position_id > estado["last_position_id"]: - mensaje_log.extend(_procesar_trigger_posicion(route_id, payload.position_id, db)) - - estado["last_position_id"] = payload.position_id - estado["last_timestamp"] = timestamp - estado["gps_ok"] = True - estado["gps_alert_sent"] = False + mensajes = _procesar_trigger_posicion(route_id, payload.position_id, db) + estado.update({ + "last_position_id": payload.position_id, + "last_timestamp": timestamp, + "gps_ok": True, + "gps_alert_sent": False, + }) if payload.position_id == 8: ROUTAS_POR_ID[route_id]["status"] = "COMPLETADO" - return { - "route_id": route_id, - "position_id": payload.position_id, - "timestamp": payload.timestamp, - "gps_ok": True, - "mensajes": mensaje_log, - } + return {"route_id": route_id, "position_id": payload.position_id, "timestamp": payload.timestamp, "gps_ok": True, "mensajes": mensajes} # =============================================================== -# ENDPOINT 1: GET /api/eta/{usuario_id} -# -# Consulta el ETA de la ruta asignada al domicilio del usuario. -# -# PRIVACIDAD POR DISEÑO: -# - El usuario_id es la única info que el cliente manda -# - El route_id se resuelve internamente, NUNCA se devuelve -# - El cliente ve el ETA y el mensaje, no la infraestructura +# ENDPOINTS — VISUALIZACIÓN Y ESTADÍSTICAS (NUEVOS EN v2) # =============================================================== -@app.get("/api/eta/{usuario_id}", response_model=ETAResponse, tags=["Core"]) -def obtener_eta(usuario_id: int, db: Session = Depends(get_db)): + +@app.get("/api/dashboard", response_model=DashboardResponse, tags=["Visualización"]) +def obtener_dashboard(db: Session = Depends(get_db)): """ - Devuelve el tiempo estimado de llegada del camión para el usuario. - - Flujo: - 1. Busca el usuario en la DB - 2. Obtiene su domicilio (colonia + route_id interno) - 3. Consulta el horario de esa ruta en los datos mockeados - 4. Retorna ETA + mensaje preventivo SIN exponer el route_id + Vista global del sistema para monitoreo. + Muestra el estado de todas las rutas + métricas de usuarios. + + Útil para: + - Panel de control del operador municipal + - Demo en hackathon (muestra todo de un vistazo) """ - # Paso 1: Verificar que el usuario existe - usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() - if not usuario: - raise HTTPException( - status_code=404, - detail=f"Usuario {usuario_id} no encontrado. ¿Corriste /api/seed?" - ) + todos_usuarios = db.query(Usuario).all() + total_usuarios = len(todos_usuarios) + usuarios_con_token = sum(1 for u in todos_usuarios if u.fcm_token) + cobertura = (usuarios_con_token / total_usuarios * 100) if total_usuarios > 0 else 0.0 - # Paso 2: Verificar que tiene al menos una dirección registrada - if not usuario.direcciones: - raise HTTPException( - status_code=404, - detail=f"El usuario {usuario_id} no tiene direcciones registradas." - ) + rutas_detalle = [] + rutas_en_progreso = 0 + rutas_completadas = 0 - direccion = db.query(Domicilio).filter(Domicilio.usuario_id == usuario_id).order_by(Domicilio.id.desc()).first() - colonia = direccion.colonia - route_id = direccion.route_id # Uso INTERNO, no se devuelve - ruta = ROUTAS_POR_ID.get(route_id, {}) - estado_ruta = _obtener_estado_ruta(route_id, db) + for route_id, ruta in ROUTAS_POR_ID.items(): + estado = ROUTE_STATE.get(route_id, {}) + calculo = _calcular_eta_por_ruta(route_id) + posiciones = ruta.get("positions", []) + total_pos = len(posiciones) + actual_pos = estado.get("last_position_id", 0) + porcentaje = round((actual_pos / total_pos * 100) if total_pos > 0 else 0.0, 1) - # Paso 3: Calcular ETA usando el estado actual de la ruta - calculo = _calcular_eta_por_ruta(route_id) + status = ruta.get("status", "DESCONOCIDA") + if status == "EN_RUTA": + rutas_en_progreso += 1 + elif status == "COMPLETADO": + rutas_completadas += 1 - return ETAResponse( - usuario_id=usuario_id, - colonia=colonia, - ruta_nombre=ruta.get("name", "Ruta desconocida"), - ruta_status=estado_ruta["status"], - eta_texto=calculo["eta_texto"], - eta_minutos=calculo["eta_minutos"], - mensaje_preventivo=generar_mensaje_preventivo(calculo["eta_minutos"]), + # Contar usuarios asignados a esta ruta + usuarios_en_ruta = db.query(Domicilio).filter(Domicilio.route_id == route_id).count() + + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + + rutas_detalle.append(RutaDetalleResponse( + route_id=route_id, + name=ruta.get("name", "Desconocida"), + status=status, + truck_id=ruta.get("truck_id", 0), + posicion_actual=actual_pos, + total_posiciones=total_pos, + porcentaje_completado=porcentaje, + eta_minutos=calculo["eta_minutos"], + gps_ok=gps_ok, + usuarios_en_ruta=usuarios_en_ruta, + )) + + return DashboardResponse( + total_rutas=len(ROUTAS_POR_ID), + rutas_en_progreso=rutas_en_progreso, + rutas_completadas=rutas_completadas, + total_usuarios=total_usuarios, + usuarios_con_token=usuarios_con_token, + cobertura_notificaciones=round(cobertura, 1), + rutas=rutas_detalle, ) -# =============================================================== -# ENDPOINT 2: POST /api/simular-evento -# -# Simula que un camión generó un evento (ej. "llegando") y -# dispara notificaciones push a todos los usuarios de esa ruta. -# -# En un sistema real, este endpoint sería llamado por el GPS -# del camión o un sistema de despacho, no por el usuario. -# =============================================================== -@app.post("/api/simular-evento", response_model=SimularEventoResponse, tags=["Core"]) -def simular_evento(payload: SimularEventoRequest, db: Session = Depends(get_db)): +@app.get("/api/rutas/{route_id}/posiciones", response_model=List[PosicionGPS], tags=["Visualización"]) +def historial_posiciones(route_id: str): """ - Recibe un evento de ruta y notifica a todos sus usuarios. - - Flujo: - 1. Valida el tipo de evento y que la ruta exista - 2. Busca todos los Domicilios asignados a esa ruta - 3. Para cada domicilio -> obtiene el usuario -> envía push - 4. Retorna un log de lo que pasó con cada usuario + Devuelve el historial completo de posiciones GPS de una ruta, + marcando cuál es la posición actual del camión. + + Útil para dibujar el recorrido en un mapa en la app. """ - # Validación del tipo de evento - if payload.tipo_evento not in TIPOS_EVENTO_VALIDOS: - raise HTTPException( - status_code=400, - detail=f"tipo_evento inválido. Opciones: {TIPOS_EVENTO_VALIDOS}" - ) + ruta = ROUTAS_POR_ID.get(route_id) + if not ruta: + raise HTTPException(status_code=404, detail="Ruta no encontrada.") - # Validar que la ruta existe en nuestros datos - if payload.route_id not in HORARIOS_POR_RUTA: - raise HTTPException( - status_code=404, - detail=f"Ruta {payload.route_id} no encontrada. Rutas válidas: {list(HORARIOS_POR_RUTA.keys())}" - ) + estado = ROUTE_STATE.get(route_id, {}) + posicion_actual = estado.get("last_position_id", 0) - # Buscar todos los domicilios asignados a esta ruta - domicilios = db.query(Domicilio).filter(Domicilio.route_id == payload.route_id).all() + resultado = [] + for pos in ruta.get("positions", []): + resultado.append(PosicionGPS( + position_id=pos["positionId"], + lat=pos["lat"], + lng=pos["lng"], + speed=pos["speed"], + timestamp=pos["timestamp"], + es_actual=(pos["positionId"] == posicion_actual), + )) + return resultado - if not domicilios: - return SimularEventoResponse( - usuarios_notificados=0, - route_id=payload.route_id, - tipo_evento=payload.tipo_evento, - detalle=["No hay usuarios registrados en esta ruta."] - ) - # Construir el mensaje según el tipo de evento - mensajes_por_evento = { - "en_camino": ("🚛 Camión en camino", "El camión de recolección está en ruta hacia tu zona."), - "llegando": ("⚠️ ¡El camión está cerca!", "Saca tu basura ahora, el camión llega en minutos."), - "completado": ("✅ Recolección completada", "El camión ya pasó por tu zona. Nos vemos mañana."), - "retrasado": ("🕐 Retraso en ruta", "El camión se ha retrasado. Te avisaremos cuando esté cerca."), - } - titulo, cuerpo = mensajes_por_evento[payload.tipo_evento] +@app.get("/api/rutas/resumen", tags=["Visualización"]) +def resumen_rutas(db: Session = Depends(get_db)): + """ + Vista rápida y ligera de todas las rutas activas. + Diseñada para refrescarse cada 30s en la app sin sobrecargar. + """ + resumen = [] + for route_id, ruta in ROUTAS_POR_ID.items(): + estado = ROUTE_STATE.get(route_id, {}) + ultimo = estado.get("last_timestamp", datetime.now(timezone.utc)) + gps_ok = (datetime.now(timezone.utc) - ultimo) < timedelta(minutes=10) + calculo = _calcular_eta_por_ruta(route_id) + resumen.append({ + "route_id": route_id, + "name": ruta.get("name"), + "status": ruta.get("status"), + "eta_minutos": calculo["eta_minutos"], + "gps_ok": gps_ok, + "posicion_actual": estado.get("last_position_id", 0), + "total_posiciones": len(ruta.get("positions", [])), + }) + return {"rutas": resumen, "timestamp": datetime.now(timezone.utc).isoformat()} - # Enviar notificación a cada usuario de la ruta - detalle_log = [] - usuarios_notificados = 0 - for domicilio in domicilios: - usuario = domicilio.usuario +@app.get("/api/estadisticas/colonias", response_model=List[ColoniaEstadisticaResponse], tags=["Visualización"]) +def estadisticas_por_colonia(db: Session = Depends(get_db)): + """ + Estadísticas por colonia: cuántos usuarios hay y cuántos + tienen notificaciones activas. Útil para el operador. + """ + resultado = [] + for item in HORARIOS_POR_COLONIA: + colonia = item["colonia"] + route_id = item["routeId"] - if not usuario: - detalle_log.append(f"Domicilio ID {domicilio.id}: Sin usuario asociado (dato corrupto).") - continue + domicilios = db.query(Domicilio).filter(Domicilio.colonia == colonia).all() + total = len(domicilios) + con_notif = sum(1 for d in domicilios if d.usuario and d.usuario.fcm_token) - if not usuario.fcm_token: - # Sin token no hay push. En producción: guardar en cola para reintentar - detalle_log.append(f"Usuario '{usuario.nombre}' (ID {usuario.id}): Sin FCM token. Push omitido.") - continue - - # Intentar enviar la notificación (real o simulada) - exito = enviar_notificacion_firebase(usuario.fcm_token, titulo, cuerpo) - - if exito: - usuarios_notificados += 1 - detalle_log.append(f"✅ Push enviado a '{usuario.nombre}' (ID {usuario.id}) en {domicilio.colonia}.") - else: - detalle_log.append(f"❌ Fallo push para '{usuario.nombre}' (ID {usuario.id}).") - - logger.info(f"Evento '{payload.tipo_evento}' en {payload.route_id}: {usuarios_notificados}/{len(domicilios)} notificados.") - - return SimularEventoResponse( - usuarios_notificados=usuarios_notificados, - route_id=payload.route_id, - tipo_evento=payload.tipo_evento, - detalle=detalle_log - ) + resultado.append(ColoniaEstadisticaResponse( + colonia=colonia, + route_id=route_id, + ruta_nombre=ROUTAS_POR_ID.get(route_id, {}).get("name", "Desconocida"), + horario=item.get("horarioEstimado", ""), + total_usuarios=total, + usuarios_con_notificaciones=con_notif, + )) + return resultado # =============================================================== -# ENDPOINT: GET /api/colonias -# -# Devuelve la lista de colonias disponibles para el Dropdown -# del login en Flutter. Simple y directo. +# ENDPOINTS — UTILIDADES # =============================================================== + @app.get("/api/colonias", tags=["Utilidades"]) def listar_colonias(): - """ - Lista todas las colonias disponibles. - Flutter las usa para poblar el Dropdown del login screen. - """ return {"colonias": [item["colonia"] for item in HORARIOS_POR_COLONIA]} -# =============================================================== -# ENDPOINT: PUT /api/usuarios/{usuario_id}/fcm-token -# -# Flutter llama este endpoint al iniciar la app para registrar -# o actualizar el FCM token del dispositivo. -# =============================================================== -class ActualizarTokenRequest(BaseModel): - fcm_token: str +@app.post("/api/simular-evento", response_model=SimularEventoResponse, tags=["Core"]) +def simular_evento(payload: SimularEventoRequest, db: Session = Depends(get_db)): + if payload.tipo_evento not in TIPOS_EVENTO_VALIDOS: + raise HTTPException(status_code=400, detail=f"tipo_evento inválido. Opciones: {TIPOS_EVENTO_VALIDOS}") + if payload.route_id not in HORARIOS_POR_RUTA: + raise HTTPException(status_code=404, detail=f"Ruta {payload.route_id} no encontrada.") -@app.put("/api/usuarios/{usuario_id}/fcm-token", tags=["Utilidades"]) -def actualizar_fcm_token( - usuario_id: int, - payload: ActualizarTokenRequest, - db: Session = Depends(get_db) -): - """ - Actualiza el FCM token de un usuario. - - Flutter llama esto cuando: - - El usuario inicia sesión por primera vez - - Firebase renueva el token del dispositivo (pasa periódicamente) - """ - usuario = db.query(Usuario).filter(Usuario.id == usuario_id).first() - if not usuario: - raise HTTPException(status_code=404, detail="Usuario no encontrado.") + domicilios = db.query(Domicilio).filter(Domicilio.route_id == payload.route_id).all() + if not domicilios: + return SimularEventoResponse(usuarios_notificados=0, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=["No hay usuarios registrados en esta ruta."]) - usuario.fcm_token = payload.fcm_token + mensajes_por_evento = { + "en_camino": ("🚛 Camión en camino", "El camión de recolección está en ruta hacia tu zona."), + "llegando": ("⚠️ ¡El camión está cerca!", "Saca tu basura ahora, el camión llega en minutos."), + "completado": ("✅ Recolección completada", "El camión ya pasó por tu zona. Nos vemos mañana."), + "retrasado": ("🕐 Retraso en ruta", "El camión se ha retrasado. Te avisaremos cuando esté cerca."), + } + titulo, cuerpo = mensajes_por_evento[payload.tipo_evento] + + detalle_log = [] + notificados = 0 + for domicilio in domicilios: + usuario = domicilio.usuario + if not usuario: + detalle_log.append(f"Domicilio ID {domicilio.id}: sin usuario asociado.") + continue + if not usuario.fcm_token: + detalle_log.append(f"Usuario '{usuario.nombre}': sin FCM token.") + continue + if enviar_notificacion_firebase(usuario.fcm_token, titulo, cuerpo): + notificados += 1 + detalle_log.append(f"✅ Push a {usuario.nombre} ({domicilio.colonia})") + else: + detalle_log.append(f"❌ Fallo push a {usuario.nombre}") + + return SimularEventoResponse(usuarios_notificados=notificados, route_id=payload.route_id, tipo_evento=payload.tipo_evento, detalle=detalle_log) + + +@app.post("/api/seed", tags=["Utilidades"]) +def seed_datos(db: Session = Depends(get_db)): + if db.query(Usuario).count() > 0: + return {"mensaje": "Ya hay datos en la DB. No se hizo nada."} + usuarios_seed = [ + {"nombre": "Ana García", "email": "ana@example.com", "colonia": "Zona Centro", "direccion": "Calle Principal 123", "password": "123456"}, + {"nombre": "Carlos López", "email": "carlos@example.com", "colonia": "Las Arboledas", "direccion": "Av. Hidalgo 45", "password": "123456"}, + {"nombre": "María Torres", "email": "maria@example.com", "colonia": "San Juanico", "direccion": "Calle Luna 12", "password": "123456"}, + {"nombre": "Pedro Ruiz", "email": "pedro@example.com", "colonia": "Los Olivos", "direccion": "Calle Sol 78", "password": "123456"}, + ] + for u in usuarios_seed: + route_id = COLONIAS_A_RUTAS.get(u["colonia"], "RUTA-01") + usuario = Usuario(nombre=u["nombre"], email=u["email"], password_hash=hash_password(u["password"])) + db.add(usuario) + db.flush() + db.add(Domicilio(usuario_id=usuario.id, colonia=u["colonia"], direccion=u["direccion"], route_id=route_id)) db.commit() - logger.info(f"FCM token actualizado para usuario {usuario_id}") - return {"mensaje": f"Token actualizado para usuario {usuario_id}"} + logger.info("✅ Seed completado: 4 usuarios creados con bcrypt") + return {"mensaje": "Seed exitoso. Passwords: 123456 para todos."} + +# Schema Pydantic para la respuesta +# (agregar junto a los otros schemas en main.py) + +class SubseccionInfo(BaseModel): + subtitulo: str + texto: str + +class ArticuloInfo(BaseModel): + id: str + categoria: str + emoji: str + titulo: str + resumen: str + contenido: List[SubseccionInfo] + consejo_rapido: str # --------------------------------------------------------------- -# PUNTO DE ENTRADA PARA DESARROLLO DIRECTO -# Corre con: python main.py (o preferiblemente: uvicorn main:app --reload) +# ENDPOINTS DE INFORMACIÓN +# (agregar en la sección de endpoints de main.py) +# --------------------------------------------------------------- + +@app.get("/api/info", tags=["Información"]) +def listar_articulos(): + """Lista todos los artículos de información relevante (solo metadatos).""" + return { + "articulos": [ + { + "id": a["id"], + "categoria": a["categoria"], + "emoji": a["emoji"], + "titulo": a["titulo"], + "resumen": a["resumen"], + } + for a in INFO_ARTICULOS + ] + } + + +@app.get("/api/info/{articulo_id}", tags=["Información"]) +def obtener_articulo(articulo_id: str): + """Devuelve el contenido completo de un artículo por su ID.""" + articulo = next((a for a in INFO_ARTICULOS if a["id"] == articulo_id), None) + if not articulo: + raise HTTPException(status_code=404, detail="Artículo no encontrado.") + return articulo # --------------------------------------------------------------- if __name__ == "__main__": import uvicorn