diff --git a/aplicacion_hack/lib/main.dart b/aplicacion_hack/lib/main.dart index 244a702..4002ec9 100644 --- a/aplicacion_hack/lib/main.dart +++ b/aplicacion_hack/lib/main.dart @@ -1,122 +1,101 @@ -import 'package:flutter/material.dart'; +// ================================================================ +// main.dart — Punto de entrada de la aplicación +// ================================================================ +// +// RESPONSABILIDADES: +// 1. Inicializar Firebase (requerido antes de runApp) +// 2. Configurar el tema visual de la app +// 3. Definir el router básico de pantallas +// +// ATAJO DE HACKATHON: +// Sin state management complejo (Riverpod/Bloc). Usamos +// setState + shared_preferences para el MVP. Suficiente. +// ================================================================ -void main() { - runApp(const MyApp()); +import 'package:flutter/material.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'screens/login_screen.dart'; +import 'screens/home_screen.dart'; + +// ---------------------------------------------------------------- +// HANDLER DE MENSAJES EN BACKGROUND +// +// Firebase requiere que este handler sea una función TOP-LEVEL +// (fuera de cualquier clase). Se ejecuta cuando llega una +// notificación y la app está en segundo plano o cerrada. +// ---------------------------------------------------------------- +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // IMPORTANTE: Si el handler hace operaciones async pesadas, + // también hay que inicializar Firebase aquí. + await Firebase.initializeApp(); + debugPrint('📬 [Background] Mensaje recibido: ${message.messageId}'); + // TODO: Aquí puedes guardar el mensaje en local storage para mostrarlo + // después cuando el usuario abra la app. } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +// ---------------------------------------------------------------- +// FUNCIÓN MAIN — Punto de entrada real de Flutter +// ---------------------------------------------------------------- +void main() async { + // WidgetsFlutterBinding.ensureInitialized() es OBLIGATORIO + // antes de cualquier código async en main(). Inicializa el + // binding entre Flutter y el sistema operativo. + WidgetsFlutterBinding.ensureInitialized(); + + // Inicializar Firebase — REQUIERE que hayas corrido: + // > flutterfire configure + // Ese comando genera lib/firebase_options.dart automáticamente. + // + // ATAJO: Si aún no tienes Firebase configurado, comenta las + // siguientes 3 líneas y la app correrá sin notificaciones. + // ------------------------------------------------------- + // await Firebase.initializeApp( + // options: DefaultFirebaseOptions.currentPlatform, + // ); + // FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + // ------------------------------------------------------- + + runApp(const ResiduosApp()); +} + +// ---------------------------------------------------------------- +// WIDGET RAÍZ DE LA APLICACIÓN +// ---------------------------------------------------------------- +class ResiduosApp extends StatelessWidget { + const ResiduosApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Recolección Inteligente', + debugShowCheckedModeBanner: false, // Quita el banner rojo de DEBUG + + // -------------------------------------------------------- + // TEMA VISUAL + // Verde oscuro = sostenibilidad y medio ambiente. + // Fácil de cambiar para el pitch/demo. + // -------------------------------------------------------- theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF2E7D32), // Verde oscuro + brightness: Brightness.light, ), + useMaterial3: true, + fontFamily: 'Roboto', ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + + // -------------------------------------------------------- + // ROUTER SIMPLE + // Dos rutas: login (/) y home (/home). + // Pasamos el usuario_id a /home via arguments. + // -------------------------------------------------------- + initialRoute: '/', + routes: { + '/': (context) => const LoginScreen(), + '/home': (context) => const HomeScreen(), + }, ); } } diff --git a/aplicacion_hack/lib/screens/home_screen.dart b/aplicacion_hack/lib/screens/home_screen.dart new file mode 100644 index 0000000..063153c --- /dev/null +++ b/aplicacion_hack/lib/screens/home_screen.dart @@ -0,0 +1,520 @@ +// ================================================================ +// lib/screens/home_screen.dart +// Pantalla Principal — Visualización de ETA y Mensajería Preventiva +// ================================================================ +// +// 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. +// ================================================================ + +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'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State + with TickerProviderStateMixin { + // ---------------------------------------------------------------- + // ESTADO LOCAL + // ---------------------------------------------------------------- + int? _usuarioId; + ETAInfo? _etaInfo; + bool _cargando = true; + String? _error; + + // Timer para auto-refresh cada 60 segundos + Timer? _refreshTimer; + + // Controlador de animación para el pulso del círculo de ETA + late AnimationController _pulseController; + late Animation _pulseAnimation; + + final ApiService _apiService = ApiService(); + + // ---------------------------------------------------------------- + // LIFECYCLE + // ---------------------------------------------------------------- + + @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); + + _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 + } else { + // Fallback: leer de shared_preferences si no viene por argumento + _cargarUsuarioDeStorage(); + } + } + } + + @override + void dispose() { + _pulseController.dispose(); + _refreshTimer?.cancel(); // MUY IMPORTANTE: cancelar timer para evitar leaks + super.dispose(); + } + + // ---------------------------------------------------------------- + // CARGAR USUARIO ID DESDE STORAGE (fallback) + // ---------------------------------------------------------------- + Future _cargarUsuarioDeStorage() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt('usuario_id'); + if (id != null) { + setState(() => _usuarioId = id); + _cargarETA(); + _iniciarAutoRefresh(); + } else { + // No hay sesión, volver al login + 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. + // ---------------------------------------------------------------- + Future _cargarETA() async { + if (_usuarioId == null) return; + + // Solo mostrar spinner en la carga inicial, no en refresh silencioso + if (_etaInfo == null) { + setState(() { + _cargando = true; + _error = null; + }); + } + + try { + final eta = await _apiService.obtenerETA(_usuarioId!); + if (mounted) { + setState(() { + _etaInfo = eta; + _cargando = false; + _error = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _cargando = false; + _error = 'No se pudo conectar al servidor.\nVerifica que el backend esté corriendo.'; + }); + } + } + } + + // ---------------------------------------------------------------- + // 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 + } + + // Emoji indicador de urgencia + String _emojiSegunETA(int etaMinutos) { + if (etaMinutos <= 5) return '🔴'; + if (etaMinutos <= 15) return '🟡'; + if (etaMinutos <= 30) return '🟢'; + return '🔵'; + } + + // ================================================================ + // BUILD PRINCIPAL + // ================================================================ + @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, + end: Alignment.bottomRight, + colors: _etaInfo != null + ? [ + _colorSegunETA(_etaInfo!.etaMinutos), + _colorSegunETA(_etaInfo!.etaMinutos).withOpacity(0.7), + ] + : [const Color(0xFF2E7D32), const Color(0xFF1B5E20)], + ), + ), + child: SafeArea( + child: _buildContenido(), + ), + ), + ); + } + + 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, + ), + ), + ], + ), + // Botón de logout + IconButton( + onPressed: _cerrarSesion, + icon: const Icon(Icons.logout, color: Colors.white70), + tooltip: 'Cerrar sesión', + ), + ], + ), + ), + + const Spacer(flex: 1), + + // -------------------------------------------------------- + // 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.withOpacity(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.withOpacity(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, + ), + ), + ), + ), + + const Spacer(flex: 2), + + // -------------------------------------------------------- + // 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), + ), + 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), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white54), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/aplicacion_hack/lib/screens/login_screen.dart b/aplicacion_hack/lib/screens/login_screen.dart new file mode 100644 index 0000000..4b6b073 --- /dev/null +++ b/aplicacion_hack/lib/screens/login_screen.dart @@ -0,0 +1,357 @@ +// ================================================================ +// lib/screens/login_screen.dart +// Pantalla de Login Mockeada — Hackathon MVP +// ================================================================ +// +// 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. +// ================================================================ + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/api_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + // ---------------------------------------------------------------- + // ESTADO LOCAL + // ---------------------------------------------------------------- + + // Controlador para el TextField del ID de usuario + final TextEditingController _idController = TextEditingController(); + + // Colonia seleccionada en el Dropdown (null = no seleccionada aún) + String? _coloniaSeleccionada; + + // Lista de colonias cargadas desde el backend + List _colonias = []; + + // 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; + + // Servicio de API (instancia local, sin inyección para el hackathon) + final ApiService _apiService = ApiService(); + + // ---------------------------------------------------------------- + // LIFECYCLE + // ---------------------------------------------------------------- + + @override + void initState() { + super.initState(); + _cargarColonias(); + _verificarSesionExistente(); + } + + @override + void dispose() { + // Siempre liberar controllers para evitar memory leaks + _idController.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. + // ---------------------------------------------------------------- + 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, + ); + } + } + + // ---------------------------------------------------------------- + // 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(); + if (mounted) { + setState(() { + _colonias = colonias; + _cargandoColonias = false; + }); + } + } 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', + ]; + _cargandoColonias = false; + _errorColonias = 'Sin conexión al backend. Usando lista local.'; + }); + } + } + } + + // ---------------------------------------------------------------- + // ACCIÓN: INICIAR SESIÓN + // Valida, guarda y navega. + // ---------------------------------------------------------------- + Future _iniciarSesion() async { + // Validación básica del ID + final idTexto = _idController.text.trim(); + if (idTexto.isEmpty) { + _mostrarError('Por favor ingresa tu ID de usuario.'); + return; + } + + final usuarioId = int.tryParse(idTexto); + if (usuarioId == null || usuarioId <= 0) { + _mostrarError('El ID debe ser un número positivo (ej: 1, 2, 3, 4).'); + return; + } + + if (_coloniaSeleccionada == null) { + _mostrarError('Por favor selecciona tu colonia.'); + return; + } + + setState(() => _logueando = true); + + // Guardar la sesión en shared_preferences para no pedir login de nuevo + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('usuario_id', usuarioId); + await prefs.setString('colonia', _coloniaSeleccionada!); + + // Navegar a la pantalla principal pasando el usuario_id como argumento + if (mounted) { + Navigator.pushReplacementNamed( + context, + '/home', + arguments: usuarioId, + ); + } + } + + // ---------------------------------------------------------------- + // HELPER: Mostrar mensaje de error con SnackBar + // ---------------------------------------------------------------- + void _mostrarError(String mensaje) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(mensaje), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + ), + ); + } + + // ================================================================ + // UI + // ================================================================ + @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, + 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), + + // ------------------------------------------------ + // CAMPO: ID de Usuario + // NOTA PARA EL EQUIPO: Para la demo usa IDs 1 al 4 + // (son los que creó el seed del backend) + // ------------------------------------------------ + TextField( + controller: _idController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'ID de Usuario', + hintText: 'Ej: 1, 2, 3 ó 4', + helperText: 'Usa los IDs del seed del backend (1-4)', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 20), + + // ------------------------------------------------ + // DROPDOWN: Selección de Colonia + // ------------------------------------------------ + if (_cargandoColonias) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: CircularProgressIndicator(), + ), + ) + else ...[ + // Aviso si se usó el fallback local + if (_errorColonias != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '⚠️ $_errorColonias', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + ), + ), + ), + + // El DropdownButtonFormField necesita que los items + // vengan de _colonias, que se cargó en initState. + DropdownButtonFormField( + value: _coloniaSeleccionada, + hint: const Text('Selecciona tu colonia'), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.location_city_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + // Convierte cada String de _colonias en un DropdownMenuItem + items: _colonias.map((colonia) { + return DropdownMenuItem( + value: colonia, + child: Text(colonia), + ); + }).toList(), + onChanged: (valor) { + setState(() => _coloniaSeleccionada = valor); + }, + ), + ], + const SizedBox(height: 32), + + // ------------------------------------------------ + // BOTÓN: Entrar + // Muestra spinner mientras _logueando == true + // ------------------------------------------------ + SizedBox( + height: 56, + child: ElevatedButton( + onPressed: _logueando ? null : _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, + ), + ) + : const Text( + 'Entrar', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + const SizedBox(height: 16), + + // Nota informativa para jueces/demos + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '🧪 Demo: Usa IDs del 1 al 4. Corre primero POST /api/seed en el backend.', + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/aplicacion_hack/lib/services/api_service.dart b/aplicacion_hack/lib/services/api_service.dart new file mode 100644 index 0000000..3cbaa70 --- /dev/null +++ b/aplicacion_hack/lib/services/api_service.dart @@ -0,0 +1,157 @@ +// ================================================================ +// lib/services/api_service.dart +// Servicio de comunicación con el backend FastAPI +// ================================================================ +// +// PATRÓN: Service class singleton. +// Una sola instancia maneja todas las llamadas HTTP de la app. +// +// ATAJO DE HACKATHON: +// Usamos la librería 'http' sin abstracciones complejas. +// En producción: usar Dio con interceptors para auth headers, +// retry automático y mejor manejo de errores. +// +// CÓMO CONECTAR: +// En cada Screen que necesite datos: +// final service = ApiService(); +// final eta = await service.obtenerETA(usuarioId); +// ================================================================ + +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// ---------------------------------------------------------------- +// MODELO: ETAInfo +// Representa la respuesta del endpoint GET /api/eta/{usuario_id} +// Mapea exactamente los campos que devuelve el backend (main.py) +// ---------------------------------------------------------------- +class ETAInfo { + final int usuarioId; + final String colonia; + final String etaTexto; + final int etaMinutos; + final String mensajePreventivo; + + ETAInfo({ + required this.usuarioId, + required this.colonia, + required this.etaTexto, + required this.etaMinutos, + required this.mensajePreventivo, + }); + + // Factory constructor: convierte el JSON del backend a objeto Dart. + // Los keys del JSON deben coincidir con los fields del ETAResponse + // de Pydantic en main.py (usa snake_case, igual que FastAPI). + factory ETAInfo.fromJson(Map json) { + return ETAInfo( + usuarioId: json['usuario_id'], + colonia: json['colonia'], + etaTexto: json['eta_texto'], + etaMinutos: json['eta_minutos'], + mensajePreventivo: json['mensaje_preventivo'], + ); + } +} + +// ---------------------------------------------------------------- +// CLASE PRINCIPAL: ApiService +// ---------------------------------------------------------------- +class ApiService { + // ============================================================ + // BASE URL DEL BACKEND + // + // DESARROLLO LOCAL: + // - Android Emulator: usa 10.0.2.2 (mapea al localhost del PC) + // - iOS Simulator: usa 127.0.0.1 + // - Dispositivo físico: IP real de tu máquina en la red local + // (ej: http://192.168.1.100:8000) + // + // ATAJO: Cambia solo esta constante para apuntar a staging/prod. + // ============================================================ + static const String _baseUrl = 'http://10.0.2.2:8000'; + // static const String _baseUrl = 'http://127.0.0.1:8000'; // iOS Simulator + // static const String _baseUrl = 'http://192.168.1.XX:8000'; // Dispositivo físico + + // Timeout razonable para demo. Si el backend es lento, sube a 15s. + static const Duration _timeout = Duration(seconds: 10); + + // ---------------------------------------------------------------- + // MÉTODO: obtenerETA + // + // Llama a GET /api/eta/{usuario_id} y retorna un ETAInfo. + // Lanza una Exception si hay error de red o el servidor responde + // con error (4xx, 5xx). La UI debe manejar el try/catch. + // ---------------------------------------------------------------- + Future obtenerETA(int usuarioId) async { + final url = Uri.parse('$_baseUrl/api/eta/$usuarioId'); + + try { + // Llamada HTTP GET con timeout para no bloquear la UI para siempre + final response = await http.get(url).timeout(_timeout); + + if (response.statusCode == 200) { + // Decodifica el body JSON (viene como String, lo convertimos a Map) + final Map jsonData = json.decode(response.body); + return ETAInfo.fromJson(jsonData); + + } else if (response.statusCode == 404) { + // El usuario no existe en la DB — pide que corran el seed + throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?'); + + } else { + // Error genérico del servidor + throw Exception('Error del servidor: ${response.statusCode} - ${response.body}'); + } + + } on Exception { + // Re-lanzamos para que la UI lo maneje con un mensaje amigable + rethrow; + } + } + + // ---------------------------------------------------------------- + // MÉTODO: obtenerColonias + // + // Llama a GET /api/colonias para poblar el Dropdown del LoginScreen. + // Retorna una lista de strings con los nombres de las colonias. + // ---------------------------------------------------------------- + Future> obtenerColonias() async { + final url = Uri.parse('$_baseUrl/api/colonias'); + + final response = await http.get(url).timeout(_timeout); + + if (response.statusCode == 200) { + final Map jsonData = json.decode(response.body); + // El backend devuelve: { "colonias": ["Zona Centro", "Col. Hidalgo", ...] } + return List.from(jsonData['colonias']); + } else { + throw Exception('No se pudieron cargar las colonias.'); + } + } + + // ---------------------------------------------------------------- + // MÉTODO: registrarFcmToken + // + // Envía el FCM token del dispositivo al backend para que pueda + // enviar notificaciones push personalizadas. + // + // CUÁNDO LLAMARLO: + // - En HomeScreen al iniciar, después de obtener el token de + // FirebaseMessaging.instance.getToken() + // ---------------------------------------------------------------- + Future registrarFcmToken(int usuarioId, String fcmToken) async { + final url = Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token'); + + final response = await http.put( + url, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'fcm_token': fcmToken}), + ).timeout(_timeout); + + if (response.statusCode != 200) { + // No es crítico que falle en el hackathon, solo logueamos + throw Exception('Error registrando FCM token: ${response.statusCode}'); + } + } +} diff --git a/aplicacion_hack/pubspec.yaml b/aplicacion_hack/pubspec.yaml index ee8996c..e56682c 100644 --- a/aplicacion_hack/pubspec.yaml +++ b/aplicacion_hack/pubspec.yaml @@ -1,89 +1,52 @@ -name: aplicacion_hack -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +name: residuos_notif +description: "Sistema de Notificación Privada de Recolección de Residuos - MVP Hackathon" +publish_to: 'none' version: 1.0.0+1 environment: - sdk: ^3.12.0 + sdk: '>=3.0.0 <4.0.0' -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 + # ------------------------------------------------------------ + # http: Para llamadas REST al backend FastAPI. + # Elegimos 'http' sobre Dio por simplicidad en hackathon. + # Si necesitas interceptors o cancelación, migra a Dio después. + # ------------------------------------------------------------ + http: ^1.2.0 + + # ------------------------------------------------------------ + # firebase_core: Inicialización base de Firebase. + # REQUERIDO antes de cualquier otro plugin de Firebase. + # Configura con: flutterfire configure (requiere Firebase CLI) + # ------------------------------------------------------------ + firebase_core: ^3.0.0 + + # ------------------------------------------------------------ + # firebase_messaging: Recepción de notificaciones push (FCM). + # Se encarga de pedir permisos al usuario y obtener el FCM token + # que debemos mandar al backend para registrar el dispositivo. + # ------------------------------------------------------------ + firebase_messaging: ^15.0.0 + + # ------------------------------------------------------------ + # shared_preferences: Guardar el usuario_id localmente. + # Simula "sesión persistente" sin un sistema de auth real. + # ATAJO de hackathon: en producción usa JWT + secure storage. + # ------------------------------------------------------------ + shared_preferences: ^2.2.0 + + cupertino_icons: ^1.0.6 dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^3.0.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: + # Si agregas assets (imágenes, íconos locales), declararlos aquí: # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # - assets/images/ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d4868cc --- /dev/null +++ b/backend/main.py @@ -0,0 +1,514 @@ +""" +=============================================================== + SISTEMA DE NOTIFICACIÓN PRIVADA DE RECOLECCIÓN DE RESIDUOS + Backend MVP - Hackathon 24h +=============================================================== + Stack: FastAPI + SQLite (SQLAlchemy) + Firebase Admin SDK + + CÓMO CORRER: + pip install fastapi uvicorn sqlalchemy firebase-admin + 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.middleware.cors import CORSMiddleware +from sqlalchemy import create_engine, Column, Integer, String, ForeignKey +from sqlalchemy.orm import declarative_base, sessionmaker, Session, relationship +from pydantic import BaseModel +from typing import Optional +import logging + +# --------------------------------------------------------------- +# CONFIGURACIÓN DE LOGGING +# Útil para ver en consola qué está pasando sin un debugger real +# --------------------------------------------------------------- +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------- +# 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. +# --------------------------------------------------------------- +DATABASE_URL = "sqlite:///./hackathon.db" +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) +Base = declarative_base() + + +# =============================================================== +# MODELOS DE BASE DE DATOS (SQLAlchemy 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) + # Token FCM que Flutter registrará al iniciar la app + fcm_token = Column(String, nullable=True) + + # Relación 1-a-1 con Domicilio (un usuario, un domicilio registrado) + domicilio = relationship("Domicilio", back_populates="usuario", uselist=False) + + +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) + usuario_id = Column(Integer, ForeignKey("usuarios.id"), unique=True) + colonia = Column(String, nullable=False) + # route_id es dato interno - no lo devolvemos al cliente + route_id = Column(String, nullable=False) + + usuario = relationship("Usuario", back_populates="domicilio") + + +# Crea las tablas 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. +# =============================================================== + +# Mapeo: Nombre de Colonia -> ID de Ruta interna +# El Flutter usa estas colonias para el Dropdown del login +COLONIAS_A_RUTAS: dict[str, str] = { + "Zona Centro": "RUTA-01", + "Col. Hidalgo": "RUTA-01", + "Col. Independencia":"RUTA-02", + "Col. Obrera": "RUTA-02", + "Col. San Juan": "RUTA-03", + "Fracc. Los Pinos": "RUTA-03", + "Col. Reforma": "RUTA-04", +} + +# Horarios estimados por ruta (ETA en texto amigable para el usuario) +# Formato: { route_id: { "eta_texto": str, "eta_minutos": int } } +HORARIOS_POR_RUTA: dict[str, dict] = { + "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 45 minutos", "eta_minutos": 45}, + "RUTA-04": {"eta_texto": "Llega en aproximadamente 60 minutos", "eta_minutos": 60}, +} + +# 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 +# =============================================================== +import firebase_admin +from firebase_admin import credentials, messaging + +# --- DESCOMENTA ESTO CUANDO TENGAS EL ARCHIVO DE CREDENCIALES --- +# try: +# cred = credentials.Certificate("firebase-credentials.json") +# firebase_admin.initialize_app(cred) +# logger.info("✅ Firebase Admin SDK inicializado correctamente") +# except Exception as e: +# logger.error(f"❌ Error inicializando Firebase: {e}") +# --------------------------------------------------------------- + +FIREBASE_ACTIVO = False # Cambia a True al desbloquear Firebase + + +# =============================================================== +# INICIALIZACIÓN DE FASTAPI +# =============================================================== +app = FastAPI( + title="Sistema de Notificación de Residuos - MVP Hackathon", + description="API privada para notificaciones de recolección de basura", + version="0.1.0-hackathon" +) + +# CORS abierto para desarrollo. En producción: restringe origins. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +# --------------------------------------------------------------- +# 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: + yield db + finally: + db.close() + + +# =============================================================== +# 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. +# =============================================================== + +class ETAResponse(BaseModel): + """Respuesta del endpoint de ETA. Sin route_id por privacidad.""" + usuario_id: int + colonia: str + eta_texto: str + eta_minutos: int + mensaje_preventivo: str # Ej: "No saques tu basura aún" + + +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 + """ + route_id: str + tipo_evento: str # "en_camino" | "llegando" | "completado" | "retrasado" + + +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 + + +# =============================================================== +# UTILIDADES INTERNAS +# =============================================================== + +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: + return "⏰ Prepárate, el camión llega pronto. No saques tu basura aún." + elif eta_minutos <= 30: + return "🕐 Tienes tiempo. No saques tu basura todavía." + else: + return "😌 Aún falta bastante. Mantén tu basura adentro por ahora." + + +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}") + return True + + # --- CÓDIGO REAL DE FIREBASE (desbloquear cuando FIREBASE_ACTIVO=True) --- + try: + message = messaging.Message( + notification=messaging.Notification(title=titulo, body=cuerpo), + token=fcm_token, + ) + response = messaging.send(message) + logger.info(f"✅ Notificación enviada: {response}") + return True + except Exception as e: + logger.error(f"❌ Error enviando push a {fcm_token[:20]}...: {e}") + return False + + +# =============================================================== +# 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. +# =============================================================== +@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", "colonia": "Zona Centro", "fcm_token": "token-ana-fake-001"}, + {"nombre": "Carlos López", "colonia": "Col. Hidalgo", "fcm_token": "token-carlos-fake-002"}, + {"nombre": "María Torres", "colonia": "Col. Independencia", "fcm_token": "token-maria-fake-003"}, + {"nombre": "Pedro Ruiz", "colonia": "Col. San Juan", "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"], 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, 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"} + + +# =============================================================== +# 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 +# =============================================================== +@app.get("/api/eta/{usuario_id}", response_model=ETAResponse, tags=["Core"]) +def obtener_eta(usuario_id: int, 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 + """ + # 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?" + ) + + # Paso 2: Verificar que tiene domicilio registrado + if not usuario.domicilio: + raise HTTPException( + status_code=404, + detail=f"El usuario {usuario_id} no tiene domicilio registrado." + ) + + colonia = usuario.domicilio.colonia + route_id = usuario.domicilio.route_id # Uso INTERNO, no se devuelve + + # Paso 3: Buscar el horario de la ruta en los datos mockeados + horario = HORARIOS_POR_RUTA.get(route_id) + if not horario: + # Fallback gracioso: si la ruta no tiene horario, decimos que no hay info + raise HTTPException( + status_code=503, + detail="No hay información de horario disponible para esta zona." + ) + + # Paso 4: Construir respuesta con mensaje preventivo + eta_minutos = horario["eta_minutos"] + return ETAResponse( + usuario_id=usuario_id, + colonia=colonia, + eta_texto=horario["eta_texto"], + eta_minutos=eta_minutos, + mensaje_preventivo=generar_mensaje_preventivo(eta_minutos), + # NOTA: route_id NO está en ETAResponse -> privacidad garantizada + ) + + +# =============================================================== +# 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)): + """ + 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 + """ + # 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}" + ) + + # 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())}" + ) + + # Buscar todos los domicilios asignados a esta ruta + 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."] + ) + + # 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] + + # Enviar notificación a cada usuario de la ruta + detalle_log = [] + usuarios_notificados = 0 + + for domicilio in domicilios: + usuario = domicilio.usuario + + if not usuario: + detalle_log.append(f"Domicilio ID {domicilio.id}: Sin usuario asociado (dato corrupto).") + continue + + 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 + ) + + +# =============================================================== +# ENDPOINT: GET /api/colonias +# +# Devuelve la lista de colonias disponibles para el Dropdown +# del login en Flutter. Simple y directo. +# =============================================================== +@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": list(COLONIAS_A_RUTAS.keys())} + + +# =============================================================== +# 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.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.") + + usuario.fcm_token = payload.fcm_token + db.commit() + logger.info(f"FCM token actualizado para usuario {usuario_id}") + return {"mensaje": f"Token actualizado para usuario {usuario_id}"} + + +# --------------------------------------------------------------- +# PUNTO DE ENTRADA PARA DESARROLLO DIRECTO +# Corre con: python main.py (o preferiblemente: uvicorn main:app --reload) +# --------------------------------------------------------------- +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)