inicio de estrcutura

This commit is contained in:
hack_23031087_872edb
2026-05-22 16:00:50 -06:00
parent 8cc698abef
commit 64187ec2db
6 changed files with 1672 additions and 182 deletions

View File

@@ -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() { import 'package:flutter/material.dart';
runApp(const MyApp()); 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( 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( theme: ThemeData(
// This is the theme of your application. colorScheme: ColorScheme.fromSeed(
// seedColor: const Color(0xFF2E7D32), // Verde oscuro
// TRY THIS: Try running your application with "flutter run". You'll see brightness: Brightness.light,
// 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<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
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,
),
],
), ),
useMaterial3: true,
fontFamily: 'Roboto',
), ),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter, // --------------------------------------------------------
tooltip: 'Increment', // ROUTER SIMPLE
child: const Icon(Icons.add), // Dos rutas: login (/) y home (/home).
), // Pasamos el usuario_id a /home via arguments.
// --------------------------------------------------------
initialRoute: '/',
routes: {
'/': (context) => const LoginScreen(),
'/home': (context) => const HomeScreen(),
},
); );
} }
} }

View File

@@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
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<double> _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<double>(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<void> _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<void> _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<void> _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<void> _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),
),
),
),
],
),
),
],
);
}
}

View File

@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
// ----------------------------------------------------------------
// 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<String> _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<void> _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<void> _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<void> _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<String>(
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,
),
),
],
),
),
),
),
);
}
}

View File

@@ -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<String, dynamic> 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<ETAInfo> obtenerETA(int usuarioId) async {
final url = Uri.parse('$_baseUrl/api/eta/$usuarioId');
try {
// Llamada HTTP GET con timeout para no bloquear la UI para siempre
final response = await http.get(url).timeout(_timeout);
if (response.statusCode == 200) {
// Decodifica el body JSON (viene como String, lo convertimos a Map)
final Map<String, dynamic> jsonData = json.decode(response.body);
return ETAInfo.fromJson(jsonData);
} else if (response.statusCode == 404) {
// El usuario no existe en la DB — pide que corran el seed
throw Exception('Usuario no encontrado. ¿Corriste /api/seed en el backend?');
} else {
// Error genérico del servidor
throw Exception('Error del servidor: ${response.statusCode} - ${response.body}');
}
} on Exception {
// Re-lanzamos para que la UI lo maneje con un mensaje amigable
rethrow;
}
}
// ----------------------------------------------------------------
// MÉTODO: obtenerColonias
//
// Llama a GET /api/colonias para poblar el Dropdown del LoginScreen.
// Retorna una lista de strings con los nombres de las colonias.
// ----------------------------------------------------------------
Future<List<String>> obtenerColonias() async {
final url = Uri.parse('$_baseUrl/api/colonias');
final response = await http.get(url).timeout(_timeout);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = json.decode(response.body);
// El backend devuelve: { "colonias": ["Zona Centro", "Col. Hidalgo", ...] }
return List<String>.from(jsonData['colonias']);
} else {
throw Exception('No se pudieron cargar las colonias.');
}
}
// ----------------------------------------------------------------
// MÉTODO: registrarFcmToken
//
// Envía el FCM token del dispositivo al backend para que pueda
// enviar notificaciones push personalizadas.
//
// CUÁNDO LLAMARLO:
// - En HomeScreen al iniciar, después de obtener el token de
// FirebaseMessaging.instance.getToken()
// ----------------------------------------------------------------
Future<void> registrarFcmToken(int usuarioId, String fcmToken) async {
final url = Uri.parse('$_baseUrl/api/usuarios/$usuarioId/fcm-token');
final response = await http.put(
url,
headers: {'Content-Type': 'application/json'},
body: json.encode({'fcm_token': fcmToken}),
).timeout(_timeout);
if (response.statusCode != 200) {
// No es crítico que falle en el hackathon, solo logueamos
throw Exception('Error registrando FCM token: ${response.statusCode}');
}
}
}

View File

@@ -1,89 +1,52 @@
name: aplicacion_hack name: residuos_notif
description: "A new Flutter project." description: "Sistema de Notificación Privada de Recolección de Residuos - MVP Hackathon"
# The following line prevents the package from being accidentally published to publish_to: 'none'
# 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.
version: 1.0.0+1 version: 1.0.0+1
environment: 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: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# The following adds the Cupertino Icons font to your application. # ------------------------------------------------------------
# Use with the CupertinoIcons class for iOS style icons. # http: Para llamadas REST al backend FastAPI.
cupertino_icons: ^1.0.8 # 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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: 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 uses-material-design: true
# Si agregas assets (imágenes, íconos locales), declararlos aquí:
# To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - assets/images/
# - 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

514
backend/main.py Normal file
View File

@@ -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)