// ================================================================ // lib/screens/home_screen.dart (v2) // ================================================================ // // CAMBIOS v2: // - Muestra el nombre del usuario (guardado en prefs) // - Botón de cerrar sesión con confirmación (dialog) // - Botón de cambiar contraseña en el menú // - Datos del usuario cargados desde prefs (más rápido, sin esperar API) // ================================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; 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 { int? _usuarioId; String _nombreUsuario = ''; ETAInfo? _etaInfo; List _direcciones = []; List _colonias = []; bool _cargando = true; bool _cargandoColonias = true; String? _error; String? _errorColonias; Timer? _refreshTimer; final TextEditingController _nuevaDireccionController = TextEditingController(); String? _nuevaColoniaSeleccionada; late AnimationController _pulseController; late Animation _pulseAnimation; final ApiService _apiService = ApiService(); // ---------------------------------------------------------------- // LIFECYCLE // ---------------------------------------------------------------- @override void initState() { super.initState(); _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), ); } @override void didChangeDependencies() { super.didChangeDependencies(); if (_usuarioId == null) { final args = ModalRoute.of(context)?.settings.arguments; if (args is int) { _usuarioId = args; _inicializar(); } else { _cargarDesdeStorage(); } } } @override void dispose() { _pulseController.dispose(); _refreshTimer?.cancel(); _nuevaDireccionController.dispose(); super.dispose(); } // ---------------------------------------------------------------- // INICIALIZACIÓN // ---------------------------------------------------------------- Future _inicializar() async { // Carga el nombre desde prefs inmediatamente (sin esperar la API) final prefs = await SharedPreferences.getInstance(); if (mounted) { setState(() => _nombreUsuario = prefs.getString('nombre') ?? ''); } _cargarETA(); _iniciarAutoRefresh(); _registrarFCMToken(); _cargarUsuario(); _cargarColonias(); } Future _cargarDesdeStorage() async { final prefs = await SharedPreferences.getInstance(); final id = prefs.getInt('usuario_id'); if (id != null) { _usuarioId = id; _inicializar(); } else { if (mounted) Navigator.pushReplacementNamed(context, '/'); } } // ---------------------------------------------------------------- // CARGA DE DATOS // ---------------------------------------------------------------- Future _cargarETA() async { if (!mounted) return; setState(() { _cargando = true; _error = null; }); try { final prefs = await SharedPreferences.getInstance(); final usuarioId = prefs.getInt('usuario_id') ?? 1; final etaInfo = await _apiService.obtenerETA(usuarioId); if (mounted) { setState(() { _etaInfo = etaInfo; _cargando = false; }); } } catch (e) { debugPrint('Error backend ETA: $e'); // Mock de emergencia para demos/hackathon if (mounted) { setState(() { _etaInfo = ETAInfo( usuarioId: 1, colonia: "Centro", rutaNombre: "Ruta Poniente - Camión #4", rutaStatus: "EN_RUTA", gpsOk: true, etaTexto: "12 minutos aprox.", etaMinutos: 12, mensajePreventivo: "⚠️ El camión está a 3 cuadras. ¡Prepara tus bolsas!", ); _cargando = false; _error = null; }); } } } Future _cargarUsuario() async { if (_usuarioId == null) return; try { final usuario = await _apiService.obtenerUsuario(_usuarioId!); if (mounted) { setState(() { _direcciones = usuario.direcciones; // Actualizar nombre si el API devuelve uno diferente if (usuario.nombre.isNotEmpty) _nombreUsuario = usuario.nombre; }); // Guardar nombre actualizado en prefs final prefs = await SharedPreferences.getInstance(); await prefs.setString('nombre', usuario.nombre); } } catch (e) { debugPrint('Error cargando usuario: $e'); } } Future _cargarColonias() async { try { final colonias = await _apiService.obtenerColonias(); if (mounted) { setState(() { _colonias = colonias; _cargandoColonias = false; }); } } catch (e) { if (mounted) { setState(() { _colonias = [ 'Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes' ]; _cargandoColonias = false; _errorColonias = 'Sin conexión. Usando lista local.'; }); } } } void _iniciarAutoRefresh() { _refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _cargarETA()); } // ---------------------------------------------------------------- // FIREBASE // ---------------------------------------------------------------- Future _registrarFCMToken() async { try { final messaging = FirebaseMessaging.instance; final settings = await messaging.requestPermission( alert: true, sound: true, badge: true); if (settings.authorizationStatus == AuthorizationStatus.authorized) { final token = await messaging.getToken(); if (token != null && _usuarioId != null) { await _apiService.registrarFcmToken(_usuarioId!, token); } } FirebaseMessaging.onMessage.listen((RemoteMessage message) { if (message.notification != null && mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('🚛 ${message.notification!.body}'), backgroundColor: Colors.green.shade700, duration: const Duration(seconds: 5), )); _cargarETA(); } }); } catch (e) { debugPrint('Error FCM: $e'); } } // ---------------------------------------------------------------- // CERRAR SESIÓN — Con dialog de confirmación // ---------------------------------------------------------------- Future _confirmarCerrarSesion() async { final confirmar = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Cerrar sesión'), content: const Text('¿Seguro que quieres cerrar sesión?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700), child: const Text('Cerrar sesión', style: TextStyle(color: Colors.white)), ), ], ), ); if (confirmar == true) { _refreshTimer?.cancel(); final prefs = await SharedPreferences.getInstance(); await prefs.clear(); if (mounted) Navigator.pushReplacementNamed(context, '/'); } } // ---------------------------------------------------------------- // CAMBIAR CONTRASEÑA // ---------------------------------------------------------------- Future _mostrarCambioPassword() async { final actualCtrl = TextEditingController(); final nuevoCtrl = TextEditingController(); bool mostrarActual = false; bool mostrarNuevo = false; await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( title: const Text('Cambiar contraseña'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: actualCtrl, obscureText: !mostrarActual, decoration: InputDecoration( labelText: 'Contraseña actual', prefixIcon: const Icon(Icons.lock_outline), suffixIcon: IconButton( icon: Icon(mostrarActual ? Icons.visibility_off : Icons.visibility), onPressed: () => setDialogState(() => mostrarActual = !mostrarActual), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10)), ), ), const SizedBox(height: 16), TextField( controller: nuevoCtrl, obscureText: !mostrarNuevo, decoration: InputDecoration( labelText: 'Nueva contraseña', hintText: 'Mínimo 6 caracteres', prefixIcon: const Icon(Icons.lock_reset), suffixIcon: IconButton( icon: Icon(mostrarNuevo ? Icons.visibility_off : Icons.visibility), onPressed: () => setDialogState(() => mostrarNuevo = !mostrarNuevo), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10)), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancelar'), ), ElevatedButton( onPressed: () async { if (_usuarioId == null) return; final actual = actualCtrl.text; final nuevo = nuevoCtrl.text; if (actual.isEmpty || nuevo.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Completa ambos campos.')), ); return; } if (nuevo.length < 6) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'La nueva contraseña debe tener al menos 6 caracteres.')), ); return; } try { await _apiService.actualizarPassword( _usuarioId!, actual, nuevo); if (mounted) { Navigator.of(ctx).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('✅ Contraseña actualizada correctamente.'), backgroundColor: Colors.green, ), ); } } catch (e) { final msg = e.toString().replaceFirst('Exception: ', ''); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), backgroundColor: Colors.red.shade700), ); } } }, child: const Text('Guardar'), ), ], ), ), ); actualCtrl.dispose(); nuevoCtrl.dispose(); } // ---------------------------------------------------------------- // AGREGAR DIRECCIÓN // ---------------------------------------------------------------- Future _agregarDireccion() async { if (_usuarioId == null) return; final messenger = ScaffoldMessenger.of(context); final direccion = _nuevaDireccionController.text.trim(); if (_nuevaColoniaSeleccionada == null) { messenger.showSnackBar( const SnackBar(content: Text('Selecciona una colonia.'))); return; } if (direccion.isEmpty) { messenger .showSnackBar(const SnackBar(content: Text('Ingresa la dirección.'))); return; } try { await _apiService.agregarDireccion( _usuarioId!, _nuevaColoniaSeleccionada!, direccion); _nuevaDireccionController.clear(); _nuevaColoniaSeleccionada = null; await _cargarUsuario(); await _cargarETA(); if (mounted) { messenger.showSnackBar( const SnackBar(content: Text('✅ Dirección agregada.'))); } } catch (e) { if (mounted) { messenger.showSnackBar( const SnackBar(content: Text('No se pudo agregar la dirección.'))); } } } Future _mostrarAgregarDireccionDialog() async { if (_cargandoColonias) await _cargarColonias(); if (!mounted) return; _nuevaDireccionController.clear(); _nuevaColoniaSeleccionada = null; await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Agregar nueva dirección'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_errorColonias != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(_errorColonias!, style: TextStyle( color: Colors.orange.shade700, fontSize: 12)), ), TextField( controller: _nuevaDireccionController, decoration: const InputDecoration( labelText: 'Dirección', hintText: 'Calle, número'), ), const SizedBox(height: 16), DropdownButtonFormField( value: _nuevaColoniaSeleccionada, hint: const Text('Selecciona tu colonia'), items: _colonias .map((c) => DropdownMenuItem(value: c, child: Text(c))) .toList(), onChanged: (v) => setState(() => _nuevaColoniaSeleccionada = v), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancelar')), ElevatedButton( onPressed: () async { final navigator = Navigator.of(context); await _agregarDireccion(); if (mounted) navigator.pop(); }, child: const Text('Guardar'), ), ], ), ); } // ================================================================ // HELPERS DE UI // ================================================================ Color _colorSegunETA(int eta) { if (eta <= 5) return const Color(0xFFB71C1C); if (eta <= 15) return const Color(0xFFF57F17); if (eta <= 30) return const Color(0xFF1B5E20); return const Color(0xFF1A237E); } String _emojiSegunETA(int eta) { if (eta <= 5) return '🔴'; if (eta <= 15) return '🟡'; if (eta <= 30) return '🟢'; return '🔵'; } // ================================================================ // BUILD // ================================================================ @override Widget build(BuildContext context) { return Scaffold( body: AnimatedContainer( duration: const Duration(milliseconds: 800), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: _etaInfo != null ? [ _colorSegunETA(_etaInfo!.etaMinutos), _colorSegunETA(_etaInfo!.etaMinutos).withValues(alpha: 0.7) ] : [const Color(0xFF2E7D32), const Color(0xFF1B5E20)], ), ), child: SafeArea( child: _cargando ? const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text('Consultando estado del camión...', style: TextStyle(color: Colors.white70, fontSize: 16)), ])) : _error != null ? Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.wifi_off_rounded, size: 80, color: Colors.white54), const SizedBox(height: 16), Text(_error!, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 18)), const SizedBox(height: 32), ElevatedButton.icon( onPressed: _cargarETA, icon: const Icon(Icons.refresh), label: const Text('Reintentar'), style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.red.shade700), ), ]), )) : _buildUIConDatos(), ), ), ); } Widget _buildUIConDatos() { if (_etaInfo == null) return const SizedBox.shrink(); final eta = _etaInfo!; return SingleChildScrollView( child: Column( children: [ // ── HEADER ────────────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Saludo con nombre Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_nombreUsuario.isNotEmpty) Text( 'Hola, ${_nombreUsuario.split(' ').first} 👋', style: const TextStyle( color: Colors.white70, fontSize: 13), ), Row( children: [ const Icon(Icons.location_on, color: Colors.white70, size: 16), const SizedBox(width: 4), Text( eta.colonia, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16), ), ], ), ], ), ), // Menú de acciones Row( children: [ IconButton( onPressed: () => Navigator.pushNamed(context, '/routes'), icon: const Icon(Icons.list_alt, color: Colors.white70), tooltip: 'Ver rutas', ), // Menú de 3 puntos con opciones extra PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white70), onSelected: (value) { if (value == 'password') _mostrarCambioPassword(); if (value == 'logout') _confirmarCerrarSesion(); }, itemBuilder: (_) => [ const PopupMenuItem( value: 'password', child: Row(children: [ Icon(Icons.lock_reset, size: 20), SizedBox(width: 10), Text('Cambiar contraseña'), ]), ), const PopupMenuDivider(), const PopupMenuItem( value: 'logout', child: Row(children: [ Icon(Icons.logout, size: 20, color: Colors.red), SizedBox(width: 10), Text('Cerrar sesión', style: TextStyle(color: Colors.red)), ]), ), ], ), ], ), ], ), ), // ── INFO DE RUTA ───────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(14), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(eta.rutaNombre, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('Status: ${eta.rutaStatus}', style: const TextStyle( color: Colors.white70, fontSize: 13)), ], ), ), const Icon(Icons.local_shipping_rounded, color: Colors.white70, size: 24), ], ), ), ), // ── ALERTA GPS ─────────────────────────────────────────── if (!eta.gpsOk) Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.red.shade700.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.red.shade300), ), child: const Row(children: [ Icon(Icons.gps_off, color: Colors.white70), SizedBox(width: 10), Expanded( child: Text( 'GPS del camión desconectado. Recibirás una alerta si persiste.', style: TextStyle(color: Colors.white, fontSize: 14))), ]), ), ), // ── CÍRCULO ETA ────────────────────────────────────────── const SizedBox(height: 32), ScaleTransition( scale: _pulseAnimation, child: Container( width: 220, height: 220, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withValues(alpha: 0.15), border: Border.all(color: Colors.white, width: 3), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(_emojiSegunETA(eta.etaMinutos), style: const TextStyle(fontSize: 48)), const SizedBox(height: 4), Text('${eta.etaMinutos}', style: const TextStyle( fontSize: 64, fontWeight: FontWeight.w900, color: Colors.white, height: 1)), const Text('minutos', style: TextStyle( fontSize: 18, color: Colors.white70, fontWeight: FontWeight.w300)), ]), ), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text(eta.etaTexto, textAlign: TextAlign.center, style: const TextStyle( fontSize: 20, color: Colors.white, fontWeight: FontWeight.w500)), ), // ── MENSAJE PREVENTIVO ─────────────────────────────────── const SizedBox(height: 32), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 20, offset: const Offset(0, 8)) ], ), child: Text( eta.mensajePreventivo, textAlign: TextAlign.center, style: TextStyle( fontSize: 22, fontWeight: FontWeight.w800, color: _colorSegunETA(eta.etaMinutos), height: 1.3), ), ), ), // ── DIRECCIONES REGISTRADAS ────────────────────────────── const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Direcciones registradas', style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), const SizedBox(height: 12), if (_direcciones.isEmpty) const Text('No tienes direcciones registradas aún.', style: TextStyle(color: Colors.white70, fontSize: 14)) else for (final d in _direcciones) Padding( padding: const EdgeInsets.only(bottom: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(d.colonia, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w600)), const SizedBox(height: 2), Text(d.direccion, style: const TextStyle( color: Colors.white70, fontSize: 13)), ]), ), const SizedBox(height: 10), ElevatedButton.icon( onPressed: _mostrarAgregarDireccionDialog, icon: const Icon(Icons.add_location_alt_outlined), label: const Text('Agregar dirección'), style: ElevatedButton.styleFrom( backgroundColor: Colors.white.withValues(alpha: 0.18), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14)), ), ), ], ), ), ), // ── INFO DE SEPARACIÓN ─────────────────────────────────── const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Información relevante', style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( '• Separa orgánicos y reciclables. No mezcles líquidos con plásticos.', style: TextStyle(color: Colors.white70, fontSize: 14)), SizedBox(height: 8), Text( '• Saca tu basura a la acera solo cuando recibas la alerta de proximidad.', style: TextStyle(color: Colors.white70, fontSize: 14)), SizedBox(height: 8), Text( '• Si el GPS del camión se desconecta, recibirás una alerta de seguimiento.', style: TextStyle(color: Colors.white70, fontSize: 14)), ], ), ), ), // ── BOTÓN VER RUTAS ────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () => Navigator.pushNamed(context, '/routes'), icon: const Icon(Icons.local_shipping_rounded), label: const Text('Ver estado de rutas y simular avance'), style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.green.shade900, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16)), ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () => Navigator.pushNamed(context, '/info'), icon: const Icon(Icons.eco_rounded), label: const Text('Información relevante'), style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.green.shade900, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), ), ), ), // ── FOOTER: REFRESH ────────────────────────────────────── Padding( padding: const EdgeInsets.only(bottom: 32), child: Column(children: [ const Text('🔄 Se actualiza automáticamente cada minuto', style: TextStyle(color: Colors.white54, fontSize: 12)), const SizedBox(height: 12), 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)), ), ), ]), ), ], ), ); } }