fix: add project contents

This commit is contained in:
Diego Torres
2026-05-23 10:19:24 -06:00
parent c49179bc9c
commit cf4321a690
145 changed files with 12545 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,927 @@
// ================================================================
// lib/screens/home_screen.dart (v3 — rediseño visual)
// ================================================================
import 'dart:async';
import 'dart:ui';
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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
int? _usuarioId;
String _nombreUsuario = '';
ETAInfo? _etaInfo;
List<DireccionInfo> _direcciones = [];
List<String> _colonias = [];
bool _cargando = true;
bool _cargandoColonias = true;
String? _error;
String? _errorColonias;
Timer? _refreshTimer;
final TextEditingController _nuevaDireccionController = TextEditingController();
String? _nuevaColoniaSeleccionada;
late AnimationController _pulseController;
late AnimationController _fadeController;
late Animation<double> _pulseAnimation;
late Animation<double> _fadeAnimation;
final ApiService _apiService = ApiService();
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.06).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
}
@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();
_fadeController.dispose();
_refreshTimer?.cancel();
_nuevaDireccionController.dispose();
super.dispose();
}
Future<void> _inicializar() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) setState(() => _nombreUsuario = prefs.getString('nombre') ?? '');
_cargarETA();
_iniciarAutoRefresh();
_registrarFCMToken();
_cargarUsuario();
_cargarColonias();
}
Future<void> _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, '/');
}
}
Future<void> _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; });
_fadeController.forward(from: 0);
}
} catch (e) {
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;
});
_fadeController.forward(from: 0);
}
}
}
Future<void> _cargarUsuario() async {
if (_usuarioId == null) return;
try {
final usuario = await _apiService.obtenerUsuario(_usuarioId!);
if (mounted) {
setState(() {
_direcciones = usuario.direcciones;
if (usuario.nombre.isNotEmpty) _nombreUsuario = usuario.nombre;
});
final prefs = await SharedPreferences.getInstance();
await prefs.setString('nombre', usuario.nombre);
}
} catch (e) {
debugPrint('Error cargando usuario: $e');
}
}
Future<void> _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());
}
Future<void> _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),
behavior: SnackBarBehavior.floating,
));
_cargarETA();
}
});
} catch (e) {
debugPrint('Error FCM: $e');
}
}
// ── DIÁLOGOS ────────────────────────────────────────────────────
Future<void> _confirmarCerrarSesion() async {
final confirmar = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
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, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
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, '/');
}
}
Future<void> _mostrarCambioPassword() async {
final actualCtrl = TextEditingController();
final nuevoCtrl = TextEditingController();
bool mostrarActual = false;
bool mostrarNuevo = false;
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Cambiar contraseña'),
content: 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: () => setS(() => mostrarActual = !mostrarActual),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 12),
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: () => setS(() => mostrarNuevo = !mostrarNuevo),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancelar')),
ElevatedButton(
onPressed: () async {
if (_usuarioId == null) return;
if (actualCtrl.text.isEmpty || nuevoCtrl.text.isEmpty) return;
if (nuevoCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mínimo 6 caracteres.')));
return;
}
try {
await _apiService.actualizarPassword(_usuarioId!, actualCtrl.text, nuevoCtrl.text);
if (mounted) {
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ Contraseña actualizada.'), backgroundColor: Colors.green),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', '')), backgroundColor: Colors.red.shade700),
);
}
}
},
child: const Text('Guardar'),
),
],
),
),
);
actualCtrl.dispose();
nuevoCtrl.dispose();
}
Future<void> _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<void> _mostrarAgregarDireccionDialog() async {
if (_cargandoColonias) await _cargarColonias();
if (!mounted) return;
_nuevaDireccionController.clear();
_nuevaColoniaSeleccionada = null;
await showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Agregar 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<String>(
initialValue: _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 nav = Navigator.of(context);
await _agregarDireccion();
if (mounted) nav.pop();
},
child: const Text('Guardar'),
),
],
),
);
}
// ── HELPERS ─────────────────────────────────────────────────────
Color _colorSegunETA(int eta) {
if (eta <= 5) return const Color(0xFFB71C1C);
if (eta <= 15) return const Color(0xFFE65100);
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 '🔵';
}
String _textoEstado(int eta) {
if (eta <= 5) return '¡Saca tu basura AHORA!';
if (eta <= 15) return 'Prepárate, viene pronto';
if (eta <= 30) return 'En camino a tu zona';
return 'Aún falta un rato';
}
// ================================================================
// BUILD
// ================================================================
@override
Widget build(BuildContext context) {
final baseColor = _etaInfo != null
? _colorSegunETA(_etaInfo!.etaMinutos)
: const Color(0xFF1B5E20);
return Scaffold(
body: AnimatedContainer(
duration: const Duration(milliseconds: 800),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [baseColor, baseColor.withValues(alpha: 0.85), const Color(0xFF0D1B0F)],
stops: const [0.0, 0.5, 1.0],
),
),
child: SafeArea(
child: _cargando
? _buildCargando()
: _error != null
? _buildError()
: FadeTransition(opacity: _fadeAnimation, child: _buildContenido()),
),
),
);
}
Widget _buildCargando() => const Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
SizedBox(height: 16),
Text('Consultando estado del camión...', style: TextStyle(color: Colors.white60, fontSize: 15)),
]),
);
Widget _buildError() => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.wifi_off_rounded, size: 72, color: Colors.white38),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 17)),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _cargarETA,
icon: const Icon(Icons.refresh),
label: const Text('Reintentar'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.red.shade700),
),
]),
),
);
Widget _buildContenido() {
if (_etaInfo == null) return const SizedBox.shrink();
final eta = _etaInfo!;
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
// ── HEADER ──────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_nombreUsuario.isNotEmpty)
Text(
'Hola, ${_nombreUsuario.split(' ').first} 👋',
style: const TextStyle(color: Colors.white60, fontSize: 13),
),
Row(
children: [
const Icon(Icons.location_on_rounded, color: Colors.white70, size: 15),
const SizedBox(width: 3),
Text(
eta.colonia,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 17),
),
],
),
],
),
),
IconButton(
onPressed: () => Navigator.pushNamed(context, '/mapa'),
icon: const Icon(Icons.map_rounded, color: Colors.white60),
tooltip: 'Ver mapa',
),
IconButton(
onPressed: () => Navigator.pushNamed(context, '/routes'),
icon: const Icon(Icons.local_shipping_rounded, color: Colors.white60),
tooltip: 'Ver rutas',
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white60),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
onSelected: (v) {
if (v == 'password') _mostrarCambioPassword();
if (v == 'logout') _confirmarCerrarSesion();
},
itemBuilder: (_) => [
const PopupMenuItem(
value: 'password',
child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 10), Text('Cambiar contraseña')]),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'logout',
child: Row(children: [Icon(Icons.logout, size: 18, color: Colors.red), SizedBox(width: 10), Text('Cerrar sesión', style: TextStyle(color: Colors.red))]),
),
],
),
],
),
),
// ── CHIP DE ESTADO GPS ───────────────────────────────────
if (!eta.gpsOk)
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: _GlassChip(
icon: Icons.gps_off,
label: 'GPS del camión desconectado',
color: Colors.red.shade300,
),
),
// ── CÍRCULO ETA PRINCIPAL ────────────────────────────────
const SizedBox(height: 24),
ScaleTransition(
scale: _pulseAnimation,
child: Stack(
alignment: Alignment.center,
children: [
// Anillo exterior difuso
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.06),
),
),
// Anillo con blur
ClipOval(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
border: Border.all(color: Colors.white.withValues(alpha: 0.4), width: 2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_emojiSegunETA(eta.etaMinutos), style: const TextStyle(fontSize: 36)),
const SizedBox(height: 2),
Text(
'${eta.etaMinutos}',
style: const TextStyle(fontSize: 58, fontWeight: FontWeight.w900, color: Colors.white, height: 1),
),
const Text('minutos', style: TextStyle(fontSize: 14, color: Colors.white70, letterSpacing: 1)),
],
),
),
),
),
],
),
),
const SizedBox(height: 14),
Text(
_textoEstado(eta.etaMinutos),
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
eta.etaTexto,
style: const TextStyle(color: Colors.white60, fontSize: 13),
),
// ── CARD MENSAJE PREVENTIVO ──────────────────────────────
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _GlassCard(
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Text(
eta.mensajePreventivo,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.4),
),
),
],
),
),
),
// ── INFO DE RUTA ─────────────────────────────────────────
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _GlassCard(
child: Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.local_shipping_rounded, color: Colors.white, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(eta.rutaNombre, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: eta.rutaStatus == 'EN_RUTA'
? Colors.green.withValues(alpha: 0.3)
: Colors.blue.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
),
child: Text(
eta.rutaStatus,
style: TextStyle(
color: eta.rutaStatus == 'EN_RUTA' ? Colors.greenAccent : Colors.lightBlueAccent,
fontSize: 11, fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
],
),
),
),
// ── ACCESOS RÁPIDOS ──────────────────────────────────────
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(left: 2, bottom: 12),
child: Text('Accesos rápidos', style: TextStyle(color: Colors.white60, fontSize: 12, letterSpacing: 0.8)),
),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.55,
children: [
_AccesoRapido(
icon: Icons.local_shipping_rounded,
label: 'Rutas',
sublabel: 'Estado del camión',
color: const Color(0xFF1565C0),
onTap: () => Navigator.pushNamed(context, '/routes'),
),
_AccesoRapido(
icon: Icons.analytics_rounded,
label: 'Análisis',
sublabel: 'Reportes y predicción',
color: const Color(0xFF6A1B9A),
onTap: () => Navigator.pushNamed(context, '/analytics'),
),
_AccesoRapido(
icon: Icons.report_problem_rounded,
label: 'Reportar',
sublabel: 'Enviar un reporte',
color: const Color(0xFFE65100),
onTap: () => Navigator.pushNamed(context, '/reporte'),
),
_AccesoRapido(
icon: Icons.eco_rounded,
label: 'Información',
sublabel: 'Guía de residuos',
color: const Color(0xFF2E7D32),
onTap: () => Navigator.pushNamed(context, '/info'),
),
_AccesoRapido(
icon: Icons.map_rounded,
label: 'Mapa',
sublabel: 'Ver rutas en mapa',
color: const Color(0xFF00838F),
onTap: () => Navigator.pushNamed(context, '/mapa'),
),
_AccesoRapido(
icon: Icons.add_location_alt_rounded,
label: 'Dirección',
sublabel: 'Agregar domicilio',
color: const Color(0xFFC62828),
onTap: _mostrarAgregarDireccionDialog,
),
],
),
],
),
),
// ── DIRECCIONES ──────────────────────────────────────────
if (_direcciones.isNotEmpty) ...[
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _GlassCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.home_rounded, color: Colors.white70, size: 16),
const SizedBox(width: 8),
const Text('Mis direcciones', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)),
const Spacer(),
GestureDetector(
onTap: _mostrarAgregarDireccionDialog,
child: const Icon(Icons.add_circle_outline, color: Colors.white54, size: 20),
),
],
),
const SizedBox(height: 10),
..._direcciones.map((d) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 6, height: 6,
decoration: const BoxDecoration(color: Colors.white54, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(d.colonia, style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
Text(d.direccion, style: const TextStyle(color: Colors.white54, fontSize: 12)),
],
),
),
],
),
)),
],
),
),
),
],
// ── FOOTER ───────────────────────────────────────────────
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.only(bottom: 28),
child: Column(children: [
const Text('🔄 Actualización automática cada minuto',
style: TextStyle(color: Colors.white30, fontSize: 11)),
const SizedBox(height: 10),
TextButton.icon(
onPressed: _cargarETA,
icon: const Icon(Icons.refresh, color: Colors.white38, size: 16),
label: const Text('Actualizar ahora', style: TextStyle(color: Colors.white38, fontSize: 12)),
),
]),
),
],
),
);
}
}
// ================================================================
// WIDGETS AUXILIARES
// ================================================================
/// Tarjeta con efecto glassmorphism
class _GlassCard extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
const _GlassCard({required this.child, this.padding});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
width: double.infinity,
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1),
),
child: child,
),
),
);
}
}
/// Chip de estado pequeño
class _GlassChip extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
const _GlassChip({required this.icon, required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 15),
const SizedBox(width: 6),
Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
);
}
}
/// Tarjeta de acceso rápido en el grid
class _AccesoRapido extends StatelessWidget {
final IconData icon;
final String label;
final String sublabel;
final Color color;
final VoidCallback onTap;
const _AccesoRapido({
required this.icon,
required this.label,
required this.sublabel,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.35), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: Colors.white, size: 20),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(sublabel, style: const TextStyle(color: Colors.white54, fontSize: 10), overflow: TextOverflow.ellipsis),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,808 @@
// ================================================================
// lib/screens/info_screen.dart — EcoTrack
// Pantalla de Informacion + Tutorial interactivo
// ================================================================
//
// ASSETS USADOS:
// assets/images/recycle.jpg → Separacion / reciclaje
// assets/images/bottle.png → Plasticos
// assets/images/planta.png → Composta / Medio ambiente
// assets/images/megafono.png → Residuos peligrosos / Horarios
//
// SECCIONES:
// 1. Tutorial interactivo (swipe de pasos)
// 2. Articulos de informacion por categoria
// 3. Detalle de articulo con imagen
// ================================================================
import 'package:flutter/material.dart';
// ================================================================
// MODELOS
// ================================================================
class _Subseccion {
final String subtitulo;
final String texto;
const _Subseccion(this.subtitulo, this.texto);
}
class _Articulo {
final String id;
final String categoria;
final String titulo;
final String resumen;
final String imagenAsset;
final Color color;
final List<_Subseccion> contenido;
final String consejoRapido;
const _Articulo({
required this.id,
required this.categoria,
required this.titulo,
required this.resumen,
required this.imagenAsset,
required this.color,
required this.contenido,
required this.consejoRapido,
});
}
// ================================================================
// DATOS
// ================================================================
const _articulos = [
_Articulo(
id: 'separacion',
categoria: 'Separacion',
titulo: 'Como separar correctamente tu basura',
resumen: 'La separacion correcta es el primer paso para reciclar y reducir el impacto ambiental.',
imagenAsset: 'assets/images/recycle.jpg',
color: Color(0xFF2E7D32),
consejoRapido: 'Regla facil: si vino de la naturaleza y se pudre, es organico. Si es artificial y esta limpio, es reciclable.',
contenido: [
_Subseccion('Residuos Organicos', 'Restos de comida, cascaras de frutas y verduras, posos de cafe, bolsas de te, restos de jardin. Van en bolsa oscura o cafe. Se convierten en composta.'),
_Subseccion('Inorganicos Reciclables', 'Plasticos (botellas PET, envases), papel y carton limpios, vidrio, latas de aluminio y hojalata. Van en bolsa transparente. Deben estar limpios y secos.'),
_Subseccion('No Reciclables', 'Papel higienico usado, panales, colillas de cigarro, envolturas metalizadas. Van en bolsa negra. No tienen valor de reciclaje.'),
_Subseccion('Residuos Especiales', 'Pilas, medicamentos caducados, electronicos, aceite de cocina. NUNCA los mezcles con la basura regular. Lleva pilas a puntos de acopio en supermercados.'),
],
),
_Articulo(
id: 'horarios',
categoria: 'Horarios',
titulo: 'Cuando sacar tu basura',
resumen: 'Sacar la basura en el momento correcto evita plagas, malos olores y que el camion se la pierda.',
imagenAsset: 'assets/images/megafono.png',
color: Color(0xFF1565C0),
consejoRapido: 'Espera la alerta de EcoTrack antes de salir con tus bolsas. Te ahorra tiempo y evita dejar basura expuesta.',
contenido: [
_Subseccion('El momento ideal', 'Saca tu basura cuando recibas la alerta de "Camion Cercano" en EcoTrack. Eso significa que el camion esta a menos de 15 minutos de tu domicilio.'),
_Subseccion('Por que no de noche', 'Las bolsas en la acera de noche atraen perros y fauna nocturna que las rompen y dispersan los residuos. Ademas el plastico se deteriora con la humedad nocturna.'),
_Subseccion('Si me lo pierdo', 'Si el camion ya paso, guarda tu basura hasta el siguiente dia. Nunca dejes bolsas en la via publica fuera del horario de recoleccion.'),
_Subseccion('Dias festivos', 'En dias festivos el servicio puede retrasarse o cancelarse. Activa las notificaciones de EcoTrack para recibir alertas de retraso o cambio de horario.'),
],
),
_Articulo(
id: 'plasticos',
categoria: 'Reciclaje',
titulo: 'Guia de plasticos: cuales si y cuales no',
resumen: 'No todos los plasticos son iguales. Aprende a leer el numero en el triangulo de reciclaje.',
imagenAsset: 'assets/images/bottle.png',
color: Color(0xFF00838F),
consejoRapido: 'Busca el numero dentro del triangulo en el fondo del envase. Los numeros 1 y 2 siempre van al reciclaje.',
contenido: [
_Subseccion('Plastico #1 PET', 'Botellas de agua y refrescos. El mas reciclado. Aplastalo para ahorrar espacio. Quita la tapa porque es un material diferente.'),
_Subseccion('Plastico #2 HDPE', 'Garrafones, botellas de leche, shampoo. Tambien muy reciclable. Enjuagalo antes de separarlo.'),
_Subseccion('Plastico #5 PP', 'Tapas de botellas, envases de yogur. Si se recicla pero menos centros lo aceptan.'),
_Subseccion('Plasticos 3, 6 y 7', 'PVC, unicel, policarbonato. Dificiles o imposibles de reciclar. Van a basura no reciclable.'),
_Subseccion('Bolsas de plastico', 'No van en el reciclaje de casa porque tapan las maquinas clasificadoras. Lleva tus bolsas a centros de acopio en supermercados.'),
],
),
_Articulo(
id: 'composta',
categoria: 'Compostaje',
titulo: 'Haz composta en casa',
resumen: 'Convierte tus residuos organicos en abono natural. Es mas facil de lo que crees.',
imagenAsset: 'assets/images/planta.png',
color: Color(0xFF558B2F),
consejoRapido: 'La composta lista huele a tierra mojada, no a podrido. Si huele mal, agrega mas material seco y volteala.',
contenido: [
_Subseccion('Que necesitas', 'Un contenedor con tapa, residuos organicos, tierra o tierra de hojarasca, y un poco de paciencia.'),
_Subseccion('Que puedes compostar', 'Cascaras de frutas y verduras, restos de comida cocida sin carne, posos de cafe y filtros de papel, cascaras de huevo, hojas secas.'),
_Subseccion('Que NO debes compostar', 'Carnes, pescados, lacteos, aceites ya que atraen plagas, excrementos de mascotas, plasticos ni metales.'),
_Subseccion('El proceso', 'Alterna capas de residuos organicos humedos con capas de material seco. Voltea la mezcla cada semana. En 2 a 3 meses tendras composta lista para tus plantas.'),
],
),
_Articulo(
id: 'peligrosos',
categoria: 'Residuos Especiales',
titulo: 'Residuos peligrosos: como deshacerte de ellos',
resumen: 'Pilas, medicamentos y electronicos requieren un manejo especial para no contaminar el suelo y el agua.',
imagenAsset: 'assets/images/megafono.png',
color: Color(0xFFE65100),
consejoRapido: 'Guarda una caja en casa exclusiva para residuos peligrosos. Cuando este llena, busca el punto de acopio mas cercano.',
contenido: [
_Subseccion('Pilas y baterias', 'Una sola pila AA puede contaminar 600,000 litros de agua. Guardalas en una bolsa y lleva a los puntos de acopio en Walmart, Soriana, Home Depot o OXXO.'),
_Subseccion('Medicamentos caducados', 'No los tires al drenaje ni a la basura regular. Farmacias del Ahorro y Benavides cuentan con contenedores REPARED para medicamentos.'),
_Subseccion('Electronicos RAEE', 'Celulares, computadoras, cables, focos LED. Contienen plomo, mercurio y cadmio. Lleva a tiendas de electronicos o espera las jornadas municipales.'),
_Subseccion('Aceite de cocina', 'Un litro de aceite contamina hasta 1,000 litros de agua potable. Viertelo en una botella PET con tapa y lleva a centros de acopio.'),
],
),
_Articulo(
id: 'impacto',
categoria: 'Medio Ambiente',
titulo: 'El impacto real de reciclar',
resumen: 'Numeros concretos para entender por que vale la pena separar tu basura cada dia.',
imagenAsset: 'assets/images/planta.png',
color: Color(0xFF4527A0),
consejoRapido: 'Cada lata de aluminio que reciclas ahorra energia equivalente a medio litro de gasolina. Si importa.',
contenido: [
_Subseccion('Papel y carton', 'Reciclar 1 tonelada de papel salva 17 arboles y ahorra 26,000 litros de agua. Una familia promedio genera 500 kg de papel al ano.'),
_Subseccion('Aluminio', 'Reciclar una lata de aluminio ahorra la energia suficiente para que un foco LED funcione 20 horas. El aluminio puede reciclarse infinitas veces.'),
_Subseccion('Vidrio', 'El vidrio tarda mas de 4,000 anos en degradarse. Reciclarlo reduce en 20% las emisiones de CO2 de su produccion.'),
_Subseccion('Residuos en Mexico', 'Mexico genera 120,000 toneladas de basura al dia. Solo el 9% se recicla formalmente. Si cada hogar separara correctamente, ese porcentaje podria triplicarse.'),
],
),
];
// ── Pasos del tutorial ─────────────────────────────────────────
class _PasoTutorial {
final String titulo;
final String descripcion;
final String imagenAsset;
final Color color;
final IconData icono;
const _PasoTutorial({
required this.titulo,
required this.descripcion,
required this.imagenAsset,
required this.color,
required this.icono,
});
}
const _pasosTutorial = [
_PasoTutorial(
titulo: 'Bienvenido a EcoTrack',
descripcion: 'EcoTrack te notifica cuando el camion recolector esta cerca de tu domicilio para que saques tu basura en el momento exacto.',
imagenAsset: 'assets/images/recycle.jpg',
color: Color(0xFF2E7D32),
icono: Icons.recycling_rounded,
),
_PasoTutorial(
titulo: 'Separa tus residuos',
descripcion: 'Separa tu basura en organicos, reciclables y no reciclables. Esto facilita el trabajo del camion y reduce el impacto ambiental.',
imagenAsset: 'assets/images/bottle.png',
color: Color(0xFF00838F),
icono: Icons.category_rounded,
),
_PasoTutorial(
titulo: 'Espera la alerta',
descripcion: 'Activa las notificaciones de EcoTrack. Te avisaremos cuando el camion este a menos de 15 minutos de tu casa.',
imagenAsset: 'assets/images/megafono.png',
color: Color(0xFF1565C0),
icono: Icons.notifications_active_rounded,
),
_PasoTutorial(
titulo: 'Saca la basura a tiempo',
descripcion: 'Al recibir la alerta, saca tus bolsas a la acera. Evita sacarlas muy antes para no atraer fauna y mantener limpia la calle.',
imagenAsset: 'assets/images/recycle.jpg',
color: Color(0xFFE65100),
icono: Icons.access_time_filled_rounded,
),
_PasoTutorial(
titulo: 'Envia reportes',
descripcion: 'Si el camion no paso o detectas alguna irregularidad, usa la seccion de Reportes para informar al municipio. Tu participacion mejora el servicio.',
imagenAsset: 'assets/images/megafono.png',
color: Color(0xFF6A1B9A),
icono: Icons.report_problem_rounded,
),
];
// ================================================================
// PANTALLA PRINCIPAL
// ================================================================
class InfoScreen extends StatefulWidget {
const InfoScreen({super.key});
@override
State<InfoScreen> createState() => _InfoScreenState();
}
class _InfoScreenState extends State<InfoScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
String? _categoriaFiltro;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
List<String> get _categorias =>
_articulos.map((a) => a.categoria).toSet().toList();
List<_Articulo> get _articulosFiltrados =>
_categoriaFiltro == null
? _articulos
: _articulos.where((a) => a.categoria == _categoriaFiltro).toList();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F5),
appBar: AppBar(
title: const Text('EcoTrack — Aprende'),
backgroundColor: const Color(0xFF2E7D32),
foregroundColor: Colors.white,
elevation: 0,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.play_circle_outline_rounded), text: 'Tutorial'),
Tab(icon: Icon(Icons.menu_book_rounded), text: 'Guias'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_TutorialView(),
_GuiasView(
categorias: _categorias,
categoriaFiltro: _categoriaFiltro,
articulos: _articulosFiltrados,
onFiltro: (c) => setState(() => _categoriaFiltro = c),
),
],
),
);
}
}
// ================================================================
// TAB 1: TUTORIAL INTERACTIVO
// ================================================================
class _TutorialView extends StatefulWidget {
@override
State<_TutorialView> createState() => _TutorialViewState();
}
class _TutorialViewState extends State<_TutorialView> {
final PageController _pageCtrl = PageController();
int _paginaActual = 0;
@override
void dispose() {
_pageCtrl.dispose();
super.dispose();
}
void _siguiente() {
if (_paginaActual < _pasosTutorial.length - 1) {
_pageCtrl.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
}
}
void _anterior() {
if (_paginaActual > 0) {
_pageCtrl.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Indicador de progreso
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: List.generate(_pasosTutorial.length, (i) {
final activo = i == _paginaActual;
final completado = i < _paginaActual;
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 3),
height: 4,
decoration: BoxDecoration(
color: completado
? const Color(0xFF2E7D32)
: activo
? _pasosTutorial[_paginaActual].color
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Paso ${_paginaActual + 1} de ${_pasosTutorial.length}',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
Text(
_paginaActual == _pasosTutorial.length - 1 ? 'Completado' : '',
style: const TextStyle(color: Color(0xFF2E7D32), fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
),
// Páginas del tutorial
Expanded(
child: PageView.builder(
controller: _pageCtrl,
itemCount: _pasosTutorial.length,
onPageChanged: (i) => setState(() => _paginaActual = i),
itemBuilder: (context, i) => _PaginaTutorial(paso: _pasosTutorial[i]),
),
),
// Controles de navegación
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Row(
children: [
if (_paginaActual > 0)
Expanded(
child: OutlinedButton.icon(
onPressed: _anterior,
icon: const Icon(Icons.arrow_back_rounded, size: 18),
label: const Text('Anterior'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
if (_paginaActual > 0) const SizedBox(width: 12),
Expanded(
flex: _paginaActual == 0 ? 1 : 1,
child: ElevatedButton.icon(
onPressed: _paginaActual == _pasosTutorial.length - 1 ? null : _siguiente,
icon: Icon(
_paginaActual == _pasosTutorial.length - 1
? Icons.check_circle_rounded
: Icons.arrow_forward_rounded,
size: 18,
),
label: Text(
_paginaActual == _pasosTutorial.length - 1 ? 'Listo' : 'Siguiente',
),
style: ElevatedButton.styleFrom(
backgroundColor: _pasosTutorial[_paginaActual].color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
),
),
],
),
),
],
);
}
}
class _PaginaTutorial extends StatelessWidget {
final _PasoTutorial paso;
const _PaginaTutorial({required this.paso});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
// Imagen con overlay de icono
Stack(
alignment: Alignment.bottomRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
paso.imagenAsset,
height: 220,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 220,
decoration: BoxDecoration(
color: paso.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Icon(paso.icono, size: 80, color: paso.color.withValues(alpha: 0.4)),
),
),
),
// Badge del icono
Positioned(
bottom: 16,
right: 16,
child: Container(
width: 52, height: 52,
decoration: BoxDecoration(
color: paso.color,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: paso.color.withValues(alpha: 0.4), blurRadius: 12)],
),
child: Icon(paso.icono, color: Colors.white, size: 26),
),
),
],
),
const SizedBox(height: 24),
// Titulo
Text(
paso.titulo,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: paso.color, height: 1.2),
),
const SizedBox(height: 12),
// Descripcion
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
),
child: Text(
paso.descripcion,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15, color: Color(0xFF444444), height: 1.6),
),
),
const SizedBox(height: 20),
],
),
);
}
}
// ================================================================
// TAB 2: GUIAS DE INFORMACION
// ================================================================
class _GuiasView extends StatelessWidget {
final List<String> categorias;
final String? categoriaFiltro;
final List<_Articulo> articulos;
final void Function(String?) onFiltro;
const _GuiasView({
required this.categorias,
required this.categoriaFiltro,
required this.articulos,
required this.onFiltro,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Filtros de categoria
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
_FiltroChip(
label: 'Todas',
seleccionado: categoriaFiltro == null,
color: const Color(0xFF2E7D32),
onTap: () => onFiltro(null),
),
...categorias.map((cat) {
final a = _articulos.firstWhere((x) => x.categoria == cat, orElse: () => _articulos.first);
return _FiltroChip(
label: cat,
seleccionado: categoriaFiltro == cat,
color: a.color,
onTap: () => onFiltro(cat),
);
}),
],
),
),
// Lista de articulos
Expanded(
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
itemCount: articulos.length,
itemBuilder: (context, i) => _TarjetaArticulo(
articulo: articulos[i],
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => _DetalleArticuloScreen(articulo: articulos[i])),
),
),
),
),
],
);
}
}
// ================================================================
// WIDGET: Chip de filtro
// ================================================================
class _FiltroChip extends StatelessWidget {
final String label;
final bool seleccionado;
final Color color;
final VoidCallback onTap;
const _FiltroChip({required this.label, required this.seleccionado, required this.color, required this.onTap});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: seleccionado ? color : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color, width: 1.5),
),
child: Text(
label,
style: TextStyle(color: seleccionado ? Colors.white : color, fontWeight: FontWeight.w600, fontSize: 12),
),
),
),
);
}
}
// ================================================================
// WIDGET: Tarjeta de articulo
// ================================================================
class _TarjetaArticulo extends StatelessWidget {
final _Articulo articulo;
final VoidCallback onTap;
const _TarjetaArticulo({required this.articulo, required this.onTap});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Row(
children: [
// Imagen lateral
ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(16)),
child: Image.asset(
articulo.imagenAsset,
width: 90,
height: 100,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 90, height: 100,
color: articulo.color.withValues(alpha: 0.1),
child: Icon(Icons.eco_rounded, color: articulo.color, size: 36),
),
),
),
// Contenido
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: articulo.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(articulo.categoria,
style: TextStyle(color: articulo.color, fontSize: 10, fontWeight: FontWeight.w700)),
),
const SizedBox(height: 6),
Text(articulo.titulo,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A)),
maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text(articulo.resumen,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.3),
maxLines: 2, overflow: TextOverflow.ellipsis),
],
),
),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: Icon(Icons.chevron_right_rounded, color: Colors.grey.shade400),
),
],
),
),
),
);
}
}
// ================================================================
// PANTALLA DE DETALLE
// ================================================================
class _DetalleArticuloScreen extends StatelessWidget {
final _Articulo articulo;
const _DetalleArticuloScreen({required this.articulo});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F5),
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 220,
pinned: true,
backgroundColor: articulo.color,
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
title: Text(
articulo.titulo,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
maxLines: 2,
),
background: Stack(
fit: StackFit.expand,
children: [
Image.asset(
articulo.imagenAsset,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: articulo.color),
),
// Gradiente oscuro para legibilidad del titulo
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, articulo.color.withValues(alpha: 0.85)],
stops: const [0.4, 1.0],
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badge categoria
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: articulo.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(articulo.categoria,
style: TextStyle(color: articulo.color, fontWeight: FontWeight.w700, fontSize: 12)),
),
const SizedBox(height: 12),
// Resumen
Text(articulo.resumen,
style: const TextStyle(fontSize: 15, color: Color(0xFF333333), height: 1.5)),
const SizedBox(height: 20),
// Secciones
...articulo.contenido.map((s) => _SeccionCard(seccion: s, color: articulo.color)),
const SizedBox(height: 8),
// Consejo destacado
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: articulo.color,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.lightbulb_outline_rounded, color: Colors.white.withValues(alpha: 0.9), size: 20),
const SizedBox(width: 8),
const Text('Consejo rapido',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
],
),
const SizedBox(height: 10),
Text(articulo.consejoRapido,
style: const TextStyle(color: Colors.white, fontSize: 14, height: 1.5)),
],
),
),
const SizedBox(height: 32),
],
),
),
),
],
),
);
}
}
class _SeccionCard extends StatelessWidget {
final _Subseccion seccion;
final Color color;
const _SeccionCard({required this.seccion, required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border(left: BorderSide(color: color, width: 4)),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 6, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(seccion.subtitulo,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: color)),
const SizedBox(height: 6),
Text(seccion.texto,
style: const TextStyle(fontSize: 13, color: Color(0xFF444444), height: 1.5)),
],
),
);
}
}

View File

@@ -0,0 +1,402 @@
// ================================================================
// lib/screens/login_screen.dart — EcoTrack
// ================================================================
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> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _nameCtrl = TextEditingController();
final _dirCtrl = TextEditingController();
String? _coloniaSeleccionada;
List<String> _colonias = [];
bool _esRegistro = false;
bool _cargandoColonias = true;
String? _errorColonias;
bool _logueando = false;
bool _mostrarPassword = false;
final ApiService _apiService = ApiService();
@override
void initState() {
super.initState();
_cargarColonias();
_verificarSesion();
}
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
_nameCtrl.dispose();
_dirCtrl.dispose();
super.dispose();
}
Future<void> _verificarSesion() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt('usuario_id');
if (id != null && mounted) {
Navigator.pushReplacementNamed(context, '/home', arguments: id);
}
}
Future<void> _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 al backend. Usando lista local.';
});
}
}
}
Future<void> _iniciarSesion() async {
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
if (email.isEmpty) { _error('Ingresa tu correo.'); return; }
if (password.isEmpty) { _error('Ingresa tu contraseña.'); return; }
setState(() => _logueando = true);
try {
final r = await _apiService.loginConCorreo(email, password);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('usuario_id', r['usuario_id']);
await prefs.setString('nombre', r['nombre'] ?? '');
await prefs.setString('email', email);
if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: r['usuario_id']);
} catch (e) {
_error(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _logueando = false);
}
}
Future<void> _registrarse() async {
final nombre = _nameCtrl.text.trim();
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
final dir = _dirCtrl.text.trim();
if (nombre.isEmpty) { _error('Ingresa tu nombre.'); return; }
if (email.isEmpty) { _error('Ingresa tu correo.'); return; }
if (password.length < 6) { _error('Contraseña de al menos 6 caracteres.'); return; }
if (_coloniaSeleccionada == null) { _error('Selecciona tu colonia.'); return; }
if (dir.isEmpty) { _error('Ingresa tu dirección.'); return; }
setState(() => _logueando = true);
try {
final id = await _apiService.registrarUsuario(nombre, email, password, dir, _coloniaSeleccionada!);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('usuario_id', id);
await prefs.setString('nombre', nombre);
await prefs.setString('email', email);
if (mounted) Navigator.pushReplacementNamed(context, '/home', arguments: id);
} catch (e) {
_error(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _logueando = false);
}
}
void _error(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg),
backgroundColor: Colors.red.shade700,
behavior: SnackBarBehavior.floating,
));
}
// ================================================================
// BUILD
// ================================================================
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Scaffold(
backgroundColor: const Color(0xFFF6FAF6),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
// ── LOGO ─────────────────────────────────────────────
Center(
child: Container(
width: 84, height: 84,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(22),
boxShadow: [BoxShadow(color: color.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 8))],
),
child: const Icon(Icons.recycling_rounded, size: 48, color: Colors.white),
),
),
const SizedBox(height: 18),
Text('EcoTrack',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w900, color: color, letterSpacing: -0.5),
),
const SizedBox(height: 4),
const Text('Recolección inteligente de residuos',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 13),
),
const SizedBox(height: 32),
// ── TABS LOGIN / REGISTRO ─────────────────────────────
Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_Tab(label: 'Iniciar sesión', seleccionado: !_esRegistro, color: color,
onTap: () => setState(() { _esRegistro = false; _passwordCtrl.clear(); })),
_Tab(label: 'Registrarse', seleccionado: _esRegistro, color: color,
onTap: () => setState(() { _esRegistro = true; _passwordCtrl.clear(); })),
],
),
),
const SizedBox(height: 24),
// ── CAMPOS COMUNES ────────────────────────────────────
if (_esRegistro) ...[
_Campo(ctrl: _nameCtrl, label: 'Nombre completo', icon: Icons.person_outline),
const SizedBox(height: 14),
],
_Campo(ctrl: _emailCtrl, label: 'Correo electrónico', icon: Icons.email_outlined, tipo: TextInputType.emailAddress),
const SizedBox(height: 14),
_Campo(
ctrl: _passwordCtrl,
label: 'Contraseña',
icon: Icons.lock_outline,
obscure: !_mostrarPassword,
sufijo: IconButton(
icon: Icon(_mostrarPassword ? Icons.visibility_off : Icons.visibility, size: 20),
onPressed: () => setState(() => _mostrarPassword = !_mostrarPassword),
),
),
// ── CAMPOS EXTRA REGISTRO ─────────────────────────────
if (_esRegistro) ...[
const SizedBox(height: 14),
_Campo(ctrl: _dirCtrl, label: 'Dirección', icon: Icons.home_outlined),
const SizedBox(height: 14),
if (_cargandoColonias)
const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
else ...[
if (_errorColonias != null)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(_errorColonias!, style: TextStyle(fontSize: 11, color: Colors.orange.shade700)),
),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: DropdownButtonFormField<String>(
value: _coloniaSeleccionada,
hint: const Text('Selecciona tu colonia'),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.location_city_outlined),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
items: _colonias.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
onChanged: (v) => setState(() => _coloniaSeleccionada = v),
),
),
],
const SizedBox(height: 8),
_IndicadorFortaleza(password: _passwordCtrl.text),
],
const SizedBox(height: 24),
// ── BOTÓN PRINCIPAL ───────────────────────────────────
SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _logueando ? null : (_esRegistro ? _registrarse : _iniciarSesion),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 0,
),
child: _logueando
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(_esRegistro ? 'Crear cuenta' : 'Entrar',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(10),
),
child: Text(
_esRegistro
? 'Tu contraseña se almacena de forma segura con bcrypt. Minimo 6 caracteres.'
: 'Tus datos son privados y solo se usan para notificarte cuando el camion se acerca.',
style: TextStyle(fontSize: 11, color: color),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}
// ================================================================
// WIDGETS AUXILIARES
// ================================================================
class _Tab extends StatelessWidget {
final String label;
final bool seleccionado;
final Color color;
final VoidCallback onTap;
const _Tab({required this.label, required this.seleccionado, required this.color, required this.onTap});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: seleccionado ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(9),
boxShadow: seleccionado ? [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 6)] : [],
),
child: Text(label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal,
color: seleccionado ? color : Colors.grey.shade500,
fontSize: 13,
),
),
),
),
);
}
}
class _Campo extends StatelessWidget {
final TextEditingController ctrl;
final String label;
final IconData icon;
final bool obscure;
final TextInputType tipo;
final Widget? sufijo;
const _Campo({
required this.ctrl,
required this.label,
required this.icon,
this.obscure = false,
this.tipo = TextInputType.text,
this.sufijo,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: ctrl,
obscureText: obscure,
keyboardType: tipo,
textCapitalization: tipo == TextInputType.text ? TextCapitalization.words : TextCapitalization.none,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, size: 20),
suffixIcon: sufijo,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 1.5)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
);
}
}
class _IndicadorFortaleza extends StatelessWidget {
final String password;
const _IndicadorFortaleza({required this.password});
(int, String, Color) _evaluar() {
if (password.isEmpty) return (0, '', Colors.grey);
int pts = 0;
if (password.length >= 8) pts++;
if (password.contains(RegExp(r'[A-Z]'))) pts++;
if (password.contains(RegExp(r'[0-9]'))) pts++;
if (password.contains(RegExp(r'[!@#\$%^&*]'))) pts++;
if (pts <= 1) return (1, 'Debil', Colors.red);
if (pts == 2) return (2, 'Regular', Colors.orange);
if (pts == 3) return (3, 'Buena', Colors.lightGreen);
return (4, 'Excelente', Colors.green);
}
@override
Widget build(BuildContext context) {
if (password.isEmpty) return const SizedBox.shrink();
final (nivel, etiqueta, color) = _evaluar();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: List.generate(4, (i) => Expanded(
child: Container(
margin: const EdgeInsets.only(right: 4),
height: 4,
decoration: BoxDecoration(
color: i < nivel ? color : Colors.grey.shade200,
borderRadius: BorderRadius.circular(2),
),
),
))),
const SizedBox(height: 4),
Text('Contrasena: $etiqueta', style: TextStyle(fontSize: 11, color: color)),
],
);
}
}

View File

@@ -0,0 +1,523 @@
// ================================================================
// lib/screens/mapa_rutas_screen.dart (v2 — flutter_map)
// Mapa real de Celaya con rutas estilo metro sobre OpenStreetMap
// ================================================================
//
// DEPENDENCIAS (agregar en pubspec.yaml):
// flutter_map: ^7.0.2
// latlong2: ^0.9.1
//
// SIN API KEY — usa tiles de OpenStreetMap (gratis, libre)
//
// FILTRADO:
// Solo las rutas del usuario se ven en color vivo.
// Las demás se muestran al 20% de opacidad.
// ================================================================
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/api_service.dart';
// ----------------------------------------------------------------
// DATOS DE RUTAS
// ----------------------------------------------------------------
class _RutaInfo {
final String routeId;
final String nombre;
final String colonia;
final Color color;
final List<LatLng> puntos;
const _RutaInfo({
required this.routeId,
required this.nombre,
required this.colonia,
required this.color,
required this.puntos,
});
}
final _todasLasRutas = [
_RutaInfo(
routeId: 'RUTA-01',
nombre: 'Zona Centro - Las Arboledas',
colonia: 'Zona Centro',
color: const Color(0xFF1565C0),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5185, -100.8450),
LatLng(20.5215, -100.8142),
LatLng(20.5212, -100.8175),
LatLng(20.5210, -100.8210),
LatLng(20.5235, -100.8212),
LatLng(20.5260, -100.8215),
LatLng(20.5111, -100.9037),
],
),
_RutaInfo(
routeId: 'RUTA-03',
nombre: 'Sector Poniente - San Juanico',
colonia: 'San Juanico',
color: const Color(0xFF2E7D32),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5250, -100.8510),
LatLng(20.5290, -100.8320),
LatLng(20.5315, -100.8355),
LatLng(20.5340, -100.8390),
LatLng(20.5362, -100.8425),
LatLng(20.5330, -100.8430),
LatLng(20.5111, -100.9037),
],
),
_RutaInfo(
routeId: 'RUTA-04',
nombre: 'Oriente - Los Olivos',
colonia: 'Los Olivos',
color: const Color(0xFFE65100),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5260, -100.8010),
LatLng(20.5295, -100.7890),
LatLng(20.5320, -100.7850),
LatLng(20.5350, -100.7790),
LatLng(20.5310, -100.7760),
LatLng(20.5270, -100.7820),
LatLng(20.5111, -100.9037),
],
),
_RutaInfo(
routeId: 'RUTA-05',
nombre: 'Sector Sur - Rancho Seco',
colonia: 'Rancho Seco',
color: const Color(0xFF6A1B9A),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5050, -100.8620),
LatLng(20.5020, -100.8350),
LatLng(20.4995, -100.8210),
LatLng(20.4970, -100.8150),
LatLng(20.5010, -100.8120),
LatLng(20.5060, -100.8160),
LatLng(20.5111, -100.9037),
],
),
_RutaInfo(
routeId: 'RUTA-12',
nombre: 'Nororiente - Las Insurgentes',
colonia: 'Las Insurgentes',
color: const Color(0xFFC62828),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5280, -100.8080),
LatLng(20.5320, -100.7980),
LatLng(20.5340, -100.7940),
LatLng(20.5360, -100.7900),
LatLng(20.5310, -100.7920),
LatLng(20.5270, -100.8020),
LatLng(20.5111, -100.9037),
],
),
_RutaInfo(
routeId: 'RUTA-13',
nombre: 'Sector Norte - Trojes e Irrigación',
colonia: 'Trojes',
color: const Color(0xFF00838F),
puntos: [
LatLng(20.5111, -100.9037),
LatLng(20.5360, -100.8190),
LatLng(20.5420, -100.8080),
LatLng(20.5440, -100.8040),
LatLng(20.5460, -100.8000),
LatLng(20.5410, -100.8020),
LatLng(20.5370, -100.8120),
LatLng(20.5111, -100.9037),
],
),
];
const _coloniaARuta = {
'Zona Centro': 'RUTA-01',
'Las Arboledas': 'RUTA-01',
'San Juanico': 'RUTA-03',
'Los Olivos': 'RUTA-04',
'Rancho Seco': 'RUTA-05',
'Las Insurgentes': 'RUTA-12',
'Trojes': 'RUTA-13',
};
// ================================================================
// PANTALLA
// ================================================================
class MapaRutasScreen extends StatefulWidget {
const MapaRutasScreen({super.key});
@override
State<MapaRutasScreen> createState() => _MapaRutasScreenState();
}
class _MapaRutasScreenState extends State<MapaRutasScreen> {
Set<String> _rutasDelUsuario = {};
List<RouteInfo> _estadosBackend = [];
String? _rutaSeleccionada;
bool _cargando = true;
bool _mostrarTodas = false;
final _mapController = MapController();
final ApiService _apiService = ApiService();
static const _centro = LatLng(20.5230, -100.8550);
@override
void initState() {
super.initState();
_cargarDatos();
}
Future<void> _cargarDatos() async {
setState(() => _cargando = true);
final prefs = await SharedPreferences.getInstance();
final usuarioId = prefs.getInt('usuario_id');
if (usuarioId == null) {
if (mounted) setState(() => _cargando = false);
return;
}
try {
final rutas = await _apiService.obtenerRutas(usuarioId);
final usuario = await _apiService.obtenerUsuario(usuarioId);
final ids = <String>{};
for (final d in usuario.direcciones) {
final r = _coloniaARuta[d.colonia];
if (r != null) ids.add(r);
}
for (final r in rutas) ids.add(r.routeId);
if (mounted) {
setState(() {
_estadosBackend = rutas;
_rutasDelUsuario = ids;
_cargando = false;
});
}
} catch (_) {
if (mounted) setState(() => _cargando = false);
}
}
RouteInfo? _estadoRuta(String routeId) {
try {
return _estadosBackend.firstWhere((r) => r.routeId == routeId);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mapa de rutas — Celaya'),
backgroundColor: const Color(0xFF1B5E20),
foregroundColor: Colors.white,
actions: [
IconButton(
icon: Icon(_mostrarTodas ? Icons.visibility_off : Icons.visibility),
tooltip: _mostrarTodas ? 'Solo mis rutas' : 'Ver todas',
onPressed: () => setState(() => _mostrarTodas = !_mostrarTodas),
),
IconButton(
icon: const Icon(Icons.my_location),
onPressed: () => _mapController.move(_centro, 12.5),
),
IconButton(icon: const Icon(Icons.refresh), onPressed: _cargarDatos),
],
),
body: _cargando
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(flex: 3, child: _buildMapa()),
_buildLeyenda(),
],
),
);
}
Widget _buildMapa() {
return FlutterMap(
mapController: _mapController,
options: const MapOptions(
initialCenter: _centro,
initialZoom: 12.5,
minZoom: 11,
maxZoom: 17,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.aplicacion_hack',
maxZoom: 19,
),
PolylineLayer(polylines: _buildPolylines()),
MarkerLayer(markers: _buildMarkers()),
],
);
}
List<Polyline> _buildPolylines() {
final list = <Polyline>[];
for (final ruta in _todasLasRutas) {
final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId);
if (!_mostrarTodas && !esDelUsuario) continue;
final estado = _estadoRuta(ruta.routeId);
final posActual = estado?.lastPositionId ?? 0;
final seleccionada = _rutaSeleccionada == ruta.routeId;
final grosorBase = esDelUsuario ? 5.0 : 2.0;
final grosor = seleccionada ? grosorBase + 2 : grosorBase;
if (esDelUsuario && posActual > 1) {
// Tramo recorrido
final recorridos = ruta.puntos.take(posActual).toList();
if (recorridos.length >= 2) {
list.add(Polyline(
points: recorridos,
color: ruta.color,
strokeWidth: grosor,
));
}
// Tramo pendiente (punteado)
final pendientes = ruta.puntos.skip(posActual - 1).toList();
if (pendientes.length >= 2) {
list.add(Polyline(
points: pendientes,
color: ruta.color.withValues(alpha: 0.35),
strokeWidth: grosor - 1,
pattern: StrokePattern.dashed(segments: [12, 8]),
));
}
} else {
list.add(Polyline(
points: ruta.puntos,
color: esDelUsuario ? ruta.color : ruta.color.withValues(alpha: 0.2),
strokeWidth: grosor,
));
}
}
return list;
}
List<Marker> _buildMarkers() {
final list = <Marker>[];
// Base / Relleno Sanitario
list.add(Marker(
point: LatLng(20.5111, -100.9037),
width: 70,
height: 36,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(5),
),
child: const Text('🏭 Base',
style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
Container(width: 2, height: 4, color: Colors.grey.shade800),
Container(
width: 8, height: 8,
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
),
],
),
));
for (final ruta in _todasLasRutas) {
final esDelUsuario = _rutasDelUsuario.contains(ruta.routeId);
if (!esDelUsuario) continue;
final estado = _estadoRuta(ruta.routeId);
final posActual = estado?.lastPositionId ?? 0;
for (int i = 1; i < ruta.puntos.length - 1; i++) {
final punto = ruta.puntos[i];
final posId = i + 1;
final esCamion = posId == posActual;
if (esCamion) {
list.add(Marker(
point: punto,
width: 46,
height: 54,
child: GestureDetector(
onTap: () => setState(() =>
_rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: ruta.color,
borderRadius: BorderRadius.circular(5),
boxShadow: [BoxShadow(color: ruta.color.withValues(alpha: 0.5), blurRadius: 6)],
),
child: Text(
ruta.routeId.replaceAll('RUTA-', 'R'),
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
),
),
const Text('🚛', style: TextStyle(fontSize: 20)),
],
),
),
));
} else {
list.add(Marker(
point: punto,
width: 14,
height: 14,
child: GestureDetector(
onTap: () => setState(() =>
_rutaSeleccionada = _rutaSeleccionada == ruta.routeId ? null : ruta.routeId),
child: Container(
decoration: BoxDecoration(
color: posId < posActual ? ruta.color : Colors.white,
shape: BoxShape.circle,
border: Border.all(color: ruta.color, width: 2.5),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 2)],
),
),
),
));
}
}
}
return list;
}
Widget _buildLeyenda() {
final rutasUsuario = _todasLasRutas
.where((r) => _rutasDelUsuario.contains(r.routeId))
.toList();
if (rutasUsuario.isEmpty) {
return Container(
height: 52,
color: const Color(0xFF1B5E20),
alignment: Alignment.center,
child: const Text('Sin rutas asignadas',
style: TextStyle(color: Colors.white54, fontSize: 13)),
);
}
return Container(
height: 96,
color: const Color(0xFF1B5E20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 5, 0, 2),
child: Text(
_mostrarTodas ? 'Todas las rutas • Las tuyas están resaltadas' : 'Tus rutas — toca para centrar',
style: const TextStyle(color: Colors.white54, fontSize: 10),
),
),
Expanded(
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
itemCount: rutasUsuario.length,
itemBuilder: (context, i) {
final ruta = rutasUsuario[i];
final estado = _estadoRuta(ruta.routeId);
final posActual = estado?.lastPositionId ?? 0;
final status = estado?.status ?? 'EN_RUTA';
final seleccionada = _rutaSeleccionada == ruta.routeId;
return GestureDetector(
onTap: () {
setState(() =>
_rutaSeleccionada = seleccionada ? null : ruta.routeId);
if (!seleccionada && ruta.puntos.length > 1) {
_mapController.move(ruta.puntos[1], 13.5);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: seleccionada
? ruta.color.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: seleccionada ? ruta.color : Colors.white24,
width: seleccionada ? 2 : 1,
),
),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 9, height: 9,
decoration: BoxDecoration(color: ruta.color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(ruta.routeId,
style: TextStyle(color: ruta.color, fontWeight: FontWeight.bold, fontSize: 11)),
const SizedBox(width: 4),
Text(status == 'COMPLETADO' ? '' : '🚛',
style: const TextStyle(fontSize: 10)),
],
),
const SizedBox(height: 1),
SizedBox(
width: 115,
child: Text(ruta.colonia,
style: const TextStyle(color: Colors.white70, fontSize: 10),
overflow: TextOverflow.ellipsis),
),
const SizedBox(height: 1),
SizedBox(
width: 115,
height: 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: (posActual / 8).clamp(0.0, 1.0),
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation(ruta.color),
),
),
),
const SizedBox(height: 1),
Text(
posActual > 0 ? 'Pos. $posActual / 8' : 'Sin datos',
style: TextStyle(color: ruta.color.withValues(alpha: 0.8), fontSize: 9),
),
],
),
),
),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,538 @@
// ================================================================
// lib/screens/reporte_screen.dart
// Pantalla para que el ciudadano envíe reportes manuales
// ================================================================
//
// NAVEGAR DESDE home_screen.dart:
// Navigator.pushNamed(context, '/reporte')
//
// AGREGAR EN main.dart:
// '/reporte': (context) => const ReporteScreen(),
//
// SECCIONES:
// 1. Formulario de nuevo reporte
// 2. Historial de reportes del usuario
// ================================================================
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
// ----------------------------------------------------------------
// MODELOS
// ----------------------------------------------------------------
class ReporteInfo {
final int reporteId;
final String fecha;
final String hora;
final String colonia;
final String tipo;
final String? descripcion;
final String estado;
ReporteInfo({
required this.reporteId,
required this.fecha,
required this.hora,
required this.colonia,
required this.tipo,
this.descripcion,
required this.estado,
});
factory ReporteInfo.fromJson(Map<String, dynamic> j) => ReporteInfo(
reporteId: j['reporte_id'],
fecha: j['fecha'],
hora: j['hora'],
colonia: j['colonia'],
tipo: j['tipo'],
descripcion: j['descripcion'],
estado: j['estado'],
);
}
// ----------------------------------------------------------------
// DATOS LOCALES
// ----------------------------------------------------------------
const _tiposReporte = [
{'valor': 'CAMION_NO_PASO', 'label': 'El camión no pasó', 'emoji': '🚫', 'color': 0xFFC62828},
{'valor': 'VOLUMEN_ALTO', 'label': 'Volumen inusualmente alto', 'emoji': '📦', 'color': 0xFFE65100},
{'valor': 'BASURA_FUERA_HORARIO', 'label': 'Basura fuera de horario', 'emoji': '', 'color': 0xFFF57F17},
{'valor': 'OTRO', 'label': 'Otro problema', 'emoji': '📝', 'color': 0xFF37474F},
];
const _colonias = [
'Zona Centro', 'Las Arboledas', 'Trojes',
'San Juanico', 'Los Olivos', 'Rancho Seco', 'Las Insurgentes',
];
// ================================================================
// PANTALLA PRINCIPAL
// ================================================================
class ReporteScreen extends StatefulWidget {
const ReporteScreen({super.key});
@override
State<ReporteScreen> createState() => _ReporteScreenState();
}
class _ReporteScreenState extends State<ReporteScreen>
with SingleTickerProviderStateMixin {
static const _baseUrl = 'http://192.168.198.55:8000';
late TabController _tabController;
int? _usuarioId;
// Formulario
String? _tipoSeleccionado;
String? _coloniaSeleccionada;
final _descController = TextEditingController();
bool _enviando = false;
// Historial
List<ReporteInfo> _reportes = [];
bool _cargandoHistorial = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_cargarUsuario();
}
@override
void dispose() {
_tabController.dispose();
_descController.dispose();
super.dispose();
}
Future<void> _cargarUsuario() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt('usuario_id');
if (id != null && mounted) {
setState(() => _usuarioId = id);
_cargarHistorial();
}
}
Future<void> _cargarHistorial() async {
if (_usuarioId == null) return;
setState(() => _cargandoHistorial = true);
try {
final resp = await http
.get(Uri.parse('$_baseUrl/api/reportes/usuario/$_usuarioId'))
.timeout(const Duration(seconds: 10));
if (resp.statusCode == 200 && mounted) {
final lista = json.decode(resp.body) as List;
setState(() {
_reportes = lista.map((r) => ReporteInfo.fromJson(r)).toList();
_cargandoHistorial = false;
});
} else {
if (mounted) setState(() => _cargandoHistorial = false);
}
} catch (_) {
if (mounted) setState(() => _cargandoHistorial = false);
}
}
Future<void> _enviarReporte() async {
if (_tipoSeleccionado == null) {
_snack('Selecciona el tipo de problema.', error: true);
return;
}
if (_coloniaSeleccionada == null) {
_snack('Selecciona la colonia.', error: true);
return;
}
if (_usuarioId == null) {
_snack('No hay sesión activa.', error: true);
return;
}
setState(() => _enviando = true);
try {
final resp = await http.post(
Uri.parse('$_baseUrl/api/reportes?usuario_id=$_usuarioId'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'colonia': _coloniaSeleccionada,
'tipo': _tipoSeleccionado,
'descripcion': _descController.text.trim().isEmpty
? null
: _descController.text.trim(),
}),
).timeout(const Duration(seconds: 10));
if (resp.statusCode == 200 && mounted) {
_snack('✅ Reporte enviado correctamente. ¡Gracias!');
setState(() {
_tipoSeleccionado = null;
_coloniaSeleccionada = null;
});
_descController.clear();
_cargarHistorial();
_tabController.animateTo(1); // ir al historial
} else {
final body = json.decode(resp.body);
_snack(body['detail'] ?? 'Error al enviar el reporte.', error: true);
}
} catch (e) {
_snack('Sin conexión al servidor.', error: true);
} finally {
if (mounted) setState(() => _enviando = false);
}
}
void _snack(String msg, {bool error = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg),
backgroundColor: error ? Colors.red.shade700 : Colors.green.shade700,
behavior: SnackBarBehavior.floating,
));
}
// ================================================================
// BUILD
// ================================================================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7F5),
appBar: AppBar(
title: const Text('Reportes ciudadanos'),
backgroundColor: const Color(0xFF1B5E20),
foregroundColor: Colors.white,
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
labelColor: Colors.white,
unselectedLabelColor: Colors.white60,
tabs: const [
Tab(icon: Icon(Icons.add_circle_outline), text: 'Nuevo reporte'),
Tab(icon: Icon(Icons.history), text: 'Mis reportes'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [_buildFormulario(), _buildHistorial()],
),
);
}
// ================================================================
// TAB 1: FORMULARIO
// ================================================================
Widget _buildFormulario() {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Encabezado
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2E7D32).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2E7D32).withValues(alpha: 0.2)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('📋 Envía un reporte',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 6),
Text(
'Tu reporte se guarda en la base de datos y ayuda a mejorar '
'la logística de recolección en tu colonia.',
style: TextStyle(fontSize: 13, color: Colors.black54, height: 1.4),
),
],
),
),
const SizedBox(height: 24),
_Label('¿Qué problema ocurrió?'),
const SizedBox(height: 10),
// Selector de tipo
...(_tiposReporte.map((tipo) {
final seleccionado = _tipoSeleccionado == tipo['valor'];
final color = Color(tipo['color'] as int);
return GestureDetector(
onTap: () => setState(() => _tipoSeleccionado = tipo['valor'] as String),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: seleccionado ? color.withValues(alpha: 0.1) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: seleccionado ? color : Colors.grey.shade200,
width: seleccionado ? 2 : 1,
),
boxShadow: seleccionado
? [BoxShadow(color: color.withValues(alpha: 0.15), blurRadius: 8)]
: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 4)],
),
child: Row(
children: [
Text(tipo['emoji'] as String, style: const TextStyle(fontSize: 22)),
const SizedBox(width: 12),
Expanded(
child: Text(
tipo['label'] as String,
style: TextStyle(
fontSize: 14,
fontWeight: seleccionado ? FontWeight.bold : FontWeight.normal,
color: seleccionado ? color : Colors.black87,
),
),
),
if (seleccionado)
Icon(Icons.check_circle, color: color, size: 20),
],
),
),
);
})),
const SizedBox(height: 20),
_Label('¿En qué colonia?'),
const SizedBox(height: 10),
// Selector de colonia
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: DropdownButtonFormField<String>(
value: _coloniaSeleccionada,
hint: const Text('Selecciona tu colonia'),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.location_city_outlined),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
items: _colonias
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
.toList(),
onChanged: (v) => setState(() => _coloniaSeleccionada = v),
),
),
const SizedBox(height: 20),
_Label('Descripción adicional (opcional)'),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: TextField(
controller: _descController,
maxLines: 4,
maxLength: 300,
decoration: const InputDecoration(
hintText: 'Describe el problema con más detalle...',
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
),
),
),
const SizedBox(height: 28),
// Botón enviar
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: _enviando ? null : _enviarReporte,
icon: _enviando
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.send_rounded),
label: Text(_enviando ? 'Enviando...' : 'Enviar reporte'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2E7D32),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
'Tu reporte es anónimo para el operador y ayuda\na mejorar el servicio de recolección.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
),
const SizedBox(height: 20),
],
),
);
}
// ================================================================
// TAB 2: HISTORIAL
// ================================================================
Widget _buildHistorial() {
if (_cargandoHistorial) {
return const Center(child: CircularProgressIndicator());
}
if (_reportes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 72, color: Colors.grey.shade300),
const SizedBox(height: 16),
const Text('No has enviado reportes aún',
style: TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 8),
const Text('Usa la pestaña anterior para reportar un problema.',
style: TextStyle(fontSize: 13, color: Colors.grey)),
],
),
);
}
return RefreshIndicator(
onRefresh: _cargarHistorial,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _reportes.length,
itemBuilder: (context, i) => _TarjetaReporte(reporte: _reportes[i]),
),
);
}
}
// ================================================================
// WIDGETS AUXILIARES
// ================================================================
class _Label extends StatelessWidget {
final String text;
const _Label(this.text);
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
);
}
class _TarjetaReporte extends StatelessWidget {
final ReporteInfo reporte;
const _TarjetaReporte({required this.reporte});
Map<String, dynamic> get _tipoInfo {
return _tiposReporte.firstWhere(
(t) => t['valor'] == reporte.tipo,
orElse: () => {'label': reporte.tipo, 'emoji': '📝', 'color': 0xFF37474F},
);
}
Color get _colorEstado => reporte.estado == 'ATENDIDO'
? const Color(0xFF2E7D32)
: const Color(0xFFE65100);
@override
Widget build(BuildContext context) {
final info = _tipoInfo;
final color = Color(info['color'] as int);
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6)
],
border: Border(left: BorderSide(color: color, width: 4)),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(info['emoji'] as String, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Expanded(
child: Text(info['label'] as String,
style: TextStyle(
fontWeight: FontWeight.bold, color: color, fontSize: 14)),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _colorEstado.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
reporte.estado == 'ATENDIDO' ? '✅ Atendido' : '⏳ Pendiente',
style: TextStyle(
color: _colorEstado, fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.location_city_outlined, size: 14, color: Colors.grey.shade500),
const SizedBox(width: 4),
Text(reporte.colonia,
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
const SizedBox(width: 16),
Icon(Icons.calendar_today_outlined, size: 14, color: Colors.grey.shade500),
const SizedBox(width: 4),
Text('${reporte.fecha} ${reporte.hora.substring(0, 5)}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
],
),
if (reporte.descripcion != null && reporte.descripcion!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
reporte.descripcion!,
style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.4),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/api_service.dart';
class RouteListScreen extends StatefulWidget {
const RouteListScreen({super.key});
@override
State<RouteListScreen> createState() => _RouteListScreenState();
}
class _RouteListScreenState extends State<RouteListScreen> {
final ApiService _apiService = ApiService();
bool _cargando = true;
bool _avanzando = false;
String? _error;
List<RouteInfo> _rutas = [];
int? _usuarioId;
@override
void initState() {
super.initState();
_cargarUsuarioYRutas();
}
Future<void> _cargarUsuarioYRutas() async {
final prefs = await SharedPreferences.getInstance();
final usuarioId = prefs.getInt('usuario_id');
if (usuarioId == null) {
if (mounted) {
setState(() {
_error =
'No se encontró sesión activa. Por favor inicia sesión de nuevo.';
_cargando = false;
});
}
return;
}
_usuarioId = usuarioId;
await _cargarRutas();
}
Future<void> _cargarRutas() async {
setState(() {
_cargando = true;
_error = null;
});
if (_usuarioId == null) {
if (mounted) {
setState(() {
_error =
'No se encontró sesión activa. Por favor inicia sesión de nuevo.';
_cargando = false;
});
}
return;
}
try {
final rutas = await _apiService.obtenerRutas(_usuarioId!);
if (mounted) {
setState(() {
_rutas = rutas;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = 'No se pudieron cargar las rutas. Verifica el backend.';
});
}
} finally {
if (mounted) {
setState(() {
_cargando = false;
});
}
}
}
Future<void> _simularAvance(String routeId) async {
setState(() {
_avanzando = true;
});
if (_usuarioId == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('No se encontró sesión activa. Inicia sesión de nuevo.'),
backgroundColor: Colors.redAccent,
),
);
}
return;
}
try {
final rutaActualizada =
await _apiService.avanzarRuta(routeId, _usuarioId!);
if (mounted) {
setState(() {
final index = _rutas.indexWhere((r) => r.routeId == routeId);
if (index >= 0) {
_rutas[index] = rutaActualizada;
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'La ruta ${rutaActualizada.routeId} avanzó al siguiente tramo.'),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error al simular avance: $e'),
backgroundColor: Colors.red.shade700,
),
);
}
} finally {
if (mounted) {
setState(() {
_avanzando = false;
});
}
}
}
Color _colorEstado(String status) {
switch (status) {
case 'COMPLETADO':
return Colors.blue.shade600;
case 'EN_RUTA':
return Colors.green.shade600;
default:
return Colors.orange.shade600;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Rutas y estado del camión'),
actions: [
IconButton(
onPressed: () => Navigator.pushNamed(context, '/mapa'),
icon: const Icon(Icons.map_rounded),
tooltip: 'Ver mapa de rutas',
),
IconButton(
onPressed: _cargarRutas,
icon: const Icon(Icons.refresh),
),
],
),
body: _cargando
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 72, color: Colors.redAccent),
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _cargarRutas,
child: const Text('Reintentar'),
),
],
),
),
)
: RefreshIndicator(
onRefresh: _cargarRutas,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.green.shade200),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Simulación de rutas',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 10),
Text(
'Esta pantalla muestra el estado actual de cada camión y permite simular el siguiente tramo de la ruta para pruebas.',
style: TextStyle(fontSize: 14),
),
],
),
),
const SizedBox(height: 16),
..._rutas.map((ruta) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
ruta.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold),
),
),
Chip(
label: Text(ruta.status),
backgroundColor:
_colorEstado(ruta.status)
.withValues(alpha: 0.15),
labelStyle: TextStyle(
color: _colorEstado(ruta.status),
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 10),
Wrap(
runSpacing: 6,
spacing: 8,
children: [
_buildTag('Ruta: ${ruta.routeId}',
Colors.grey.shade200),
_buildTag(
'Posición: ${ruta.lastPositionId}',
Colors.blue.shade50),
_buildTag(
ruta.gpsOk
? 'GPS OK'
: 'GPS desconectado',
ruta.gpsOk
? Colors.green.shade50
: Colors.red.shade50,
),
],
),
const SizedBox(height: 12),
Text(
'Último reporte: ${ruta.lastTimestamp}',
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 13),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: ruta.status == 'COMPLETADO' ||
_avanzando
? null
: () => _simularAvance(ruta.routeId),
icon: const Icon(Icons.play_arrow),
label: const Text('Simular avance'),
),
],
),
),
),
);
}),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Información relevante',
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
Text(
'• Separa correctamente orgánicos, reciclables y no reciclables.',
style: TextStyle(fontSize: 14)),
SizedBox(height: 8),
Text(
'• No mezcles basura húmeda con envases secos y mantén los líquidos controlados.',
style: TextStyle(fontSize: 14)),
SizedBox(height: 8),
Text(
'• Saca tu basura cuando el camión esté cerca: así evitamos plagas y malos olores.',
style: TextStyle(fontSize: 14)),
SizedBox(height: 8),
Text(
'• Usa bolsas resistentes y cierra bien los residuos antes de ponerlos en la acera.',
style: TextStyle(fontSize: 14)),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildTag(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Text(
text,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}