diff --git a/lib/main.dart b/lib/main.dart index 66e12cc..bbbdd68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -9,120 +11,268 @@ void main() { runApp(const RecolectorApp()); } -// ======================= JSON LOCAL DE RUTAS ======================= +// ======================================================= +// JSON OFICIALES INTEGRADOS LOCALMENTE +// ======================================================= -const String rutasJson = ''' +const String notificacionesJson = ''' [ { - "id": "centro", - "zona": "Centro", - "keywords": ["centro", "luna", "calle luna", "primer cuadro"], - "dias": ["Lunes", "Miércoles", "Viernes"], - "inicio": "07:00 AM", - "fin": "09:00 AM", - "proxima": "Lunes", - "eta": "15 minutos", - "consejo": "Saca tus residuos 15 minutos antes de la llegada estimada." + "triggerEvent": "ROUTE_START", + "condition": "Cuando positionId cambia de 1 a 2", + "pushPayload": { + "title": "¡Ruta Iniciada!", + "body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos." + } }, { - "id": "norte", - "zona": "Norte", - "keywords": ["norte", "industrial", "negocio", "bodega"], - "dias": ["Martes", "Jueves"], - "inicio": "08:00 AM", - "fin": "10:00 AM", - "proxima": "Martes", - "eta": "20 minutos", - "consejo": "Compacta cartón y separa reciclables antes de la recolección." + "triggerEvent": "TRUCK_PROXIMITY", + "condition": "Cuando positionId llega a 4 (punto previo al destino)", + "pushPayload": { + "title": "Camión Cercano", + "body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera." + } }, { - "id": "sur", - "zona": "Sur", - "keywords": ["sur", "jardines", "flores"], - "dias": ["Miércoles", "Sábado"], - "inicio": "09:00 AM", - "fin": "11:00 AM", - "proxima": "Miércoles", - "eta": "18 minutos", - "consejo": "Mantén los residuos cerrados para evitar dispersión." - }, - { - "id": "poniente", - "zona": "Poniente", - "keywords": ["poniente", "sol", "atardecer", "segunda casa"], - "dias": ["Lunes", "Jueves", "Sábado"], - "inicio": "10:00 AM", - "fin": "12:00 PM", - "proxima": "Jueves", - "eta": "25 minutos", - "consejo": "Coloca los residuos en un punto visible y accesible." - }, - { - "id": "general", - "zona": "General", - "keywords": [], - "dias": ["Lunes", "Jueves"], - "inicio": "08:00 AM", - "fin": "10:00 AM", - "proxima": "Lunes", - "eta": "20 minutos", - "consejo": "Registra calle y colonia para mejorar la asignación de ruta." + "triggerEvent": "ROUTE_COMPLETED", + "condition": "Cuando positionId llega a 8 (retorno al basurero)", + "pushPayload": { + "title": "Servicio Finalizado", + "body": "El camión de tu sector ha concluido su jornada de recolección diaria." + } } ] '''; -// ======================= MODELOS ======================= +const String coloniasJson = ''' +[ + { "colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)" }, + { "colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)" }, + { "colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)" }, + { "colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)" }, + { "colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)" }, + { "colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)" }, + { "colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)" } +] +'''; -class Ruta { - final String id; - final String zona; - final List keywords; - final List dias; - final String inicio; - final String fin; - final String proxima; - final String eta; - final String consejo; +const String rutasJson = ''' +[ + { + "routeId": "RUTA-01", + "name": "Zona Centro - Las Arboledas", + "truckId": 101, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" }, + { "positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" }, + { "positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z" }, + { "positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z" }, + { "positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z" }, + { "positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z" }, + { "positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z" } + ] + }, + { + "routeId": "RUTA-03", + "name": "Sector Poniente - San Juanico", + "truckId": 103, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" }, + { "positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z" }, + { "positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z" }, + { "positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" }, + { "positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" }, + { "positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z" }, + { "positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z" } + ] + }, + { + "routeId": "RUTA-04", + "name": "Oriente - Los Olivos", + "truckId": 104, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" }, + { "positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z" }, + { "positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z" }, + { "positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z" }, + { "positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z" }, + { "positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z" }, + { "positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z" } + ] + }, + { + "routeId": "RUTA-05", + "name": "Sector Sur - Rancho Seco", + "truckId": 105, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z" }, + { "positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z" }, + { "positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z" }, + { "positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z" }, + { "positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" }, + { "positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z" }, + { "positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z" } + ] + }, + { + "routeId": "RUTA-12", + "name": "Nororiente - Las Insurgentes", + "truckId": 112, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z" }, + { "positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z" }, + { "positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z" }, + { "positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" }, + { "positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" }, + { "positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z" }, + { "positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z" } + ] + }, + { + "routeId": "RUTA-13", + "name": "Sector Norte - Trojes e Irrigación", + "truckId": 113, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z" }, + { "positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z" }, + { "positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z" }, + { "positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z" }, + { "positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" }, + { "positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z" }, + { "positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z" } + ] + } +] +'''; - Ruta({ - required this.id, - required this.zona, - required this.keywords, - required this.dias, - required this.inicio, - required this.fin, - required this.proxima, - required this.eta, - required this.consejo, +// ======================================================= +// MODELOS +// ======================================================= + +class NotificacionConfig { + final String triggerEvent; + final String condition; + final String title; + final String body; + + NotificacionConfig({ + required this.triggerEvent, + required this.condition, + required this.title, + required this.body, }); - factory Ruta.fromJson(Map json) { - return Ruta( - id: json['id'] ?? '', - zona: json['zona'] ?? '', - keywords: List.from(json['keywords'] ?? []), - dias: List.from(json['dias'] ?? []), - inicio: json['inicio'] ?? '', - fin: json['fin'] ?? '', - proxima: json['proxima'] ?? '', - eta: json['eta'] ?? '', - consejo: json['consejo'] ?? '', + factory NotificacionConfig.fromJson(Map json) { + final payload = Map.from(json['pushPayload'] ?? {}); + return NotificacionConfig( + triggerEvent: json['triggerEvent'] ?? '', + condition: json['condition'] ?? '', + title: payload['title'] ?? '', + body: payload['body'] ?? '', ); } +} - String get diasTexto => dias.join(', '); - String get horario => '$inicio - $fin'; +class ColoniaZona { + final String colonia; + final String routeId; + final String horarioEstimado; + + ColoniaZona({ + required this.colonia, + required this.routeId, + required this.horarioEstimado, + }); + + factory ColoniaZona.fromJson(Map json) { + return ColoniaZona( + colonia: json['colonia'] ?? '', + routeId: json['routeId'] ?? '', + horarioEstimado: json['horarioEstimado'] ?? '', + ); + } +} + +class RoutePosition { + final int positionId; + final double lat; + final double lng; + final int speed; + final String timestamp; + + RoutePosition({ + required this.positionId, + required this.lat, + required this.lng, + required this.speed, + required this.timestamp, + }); + + factory RoutePosition.fromJson(Map json) { + return RoutePosition( + positionId: json['positionId'] ?? 0, + lat: (json['lat'] ?? 0).toDouble(), + lng: (json['lng'] ?? 0).toDouble(), + speed: json['speed'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } +} + +class RutaOficial { + final String routeId; + final String name; + final int truckId; + final String status; + final List positions; + + RutaOficial({ + required this.routeId, + required this.name, + required this.truckId, + required this.status, + required this.positions, + }); + + factory RutaOficial.fromJson(Map json) { + final rawPositions = json['positions'] as List? ?? []; + return RutaOficial( + routeId: json['routeId'] ?? '', + name: json['name'] ?? '', + truckId: json['truckId'] ?? 0, + status: json['status'] ?? '', + positions: rawPositions + .map((e) => RoutePosition.fromJson(Map.from(e))) + .toList(), + ); + } } class Domicilio { final String tipo; final String direccion; final String colonia; + final double? lat; + final double? lng; Domicilio({ required this.tipo, required this.direccion, required this.colonia, + this.lat, + this.lng, }); String get etiqueta { @@ -140,6 +290,8 @@ class Domicilio { 'tipo': tipo, 'direccion': direccion, 'colonia': colonia, + 'lat': lat, + 'lng': lng, }; factory Domicilio.fromJson(Map json) { @@ -147,6 +299,8 @@ class Domicilio { tipo: json['tipo'] ?? 'Domicilio', direccion: json['direccion'] ?? '', colonia: json['colonia'] ?? '', + lat: json['lat'] == null ? null : (json['lat'] as num).toDouble(), + lng: json['lng'] == null ? null : (json['lng'] as num).toDouble(), ); } } @@ -177,33 +331,74 @@ class Servicio { } } -// ======================= REPOSITORIO ======================= +// ======================================================= +// REPOSITORIO LOCAL +// ======================================================= class Repo { - static List rutas() { - final raw = jsonDecode(rutasJson) as List; - return raw.map((e) => Ruta.fromJson(Map.from(e))).toList(); + static List notificaciones() { + final raw = jsonDecode(notificacionesJson) as List; + return raw + .map((e) => NotificacionConfig.fromJson(Map.from(e))) + .toList(); } - static Ruta rutaDe(Domicilio? domicilio) { - final todas = rutas(); - final general = todas.firstWhere((r) => r.id == 'general'); + static List colonias() { + final raw = jsonDecode(coloniasJson) as List; + return raw.map((e) => ColoniaZona.fromJson(Map.from(e))).toList(); + } - if (domicilio == null) return general; + static List rutas() { + final raw = jsonDecode(rutasJson) as List; + return raw.map((e) => RutaOficial.fromJson(Map.from(e))).toList(); + } - final texto = domicilio.busqueda; + static String normalizar(String text) { + return text + .toLowerCase() + .replaceAll('á', 'a') + .replaceAll('é', 'e') + .replaceAll('í', 'i') + .replaceAll('ó', 'o') + .replaceAll('ú', 'u') + .replaceAll('ü', 'u'); + } - for (final ruta in todas) { - if (ruta.id == 'general') continue; + static ColoniaZona? coloniaDe(Domicilio? domicilio) { + if (domicilio == null) return null; - for (final key in ruta.keywords) { - if (texto.contains(key.toLowerCase())) { - return ruta; - } + final texto = normalizar(domicilio.busqueda); + + for (final c in colonias()) { + final col = normalizar(c.colonia); + if (texto.contains(col)) { + return c; } } - return general; + return null; + } + + static RutaOficial rutaDe(Domicilio? domicilio) { + final todas = rutas(); + final colonia = coloniaDe(domicilio); + + if (colonia != null) { + final match = todas.where((r) => r.routeId == colonia.routeId); + if (match.isNotEmpty) return match.first; + } + + return todas.first; + } + + static String horarioDe(Domicilio? domicilio) { + final colonia = coloniaDe(domicilio); + return colonia?.horarioEstimado ?? 'Matutino (06:30 - 07:15)'; + } + + static NotificacionConfig? notificacionPorEvento(String trigger) { + final lista = notificaciones().where((n) => n.triggerEvent == trigger); + return lista.isEmpty ? null : lista.first; } static Future guardarUsuario({ @@ -221,7 +416,6 @@ class Repo { static Future> cargarUsuario() async { final prefs = await SharedPreferences.getInstance(); - return { 'nombre': prefs.getString('nombre') ?? '', 'telefono': prefs.getString('telefono') ?? '', @@ -232,41 +426,18 @@ class Repo { static Future> cargarDomicilios() async { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString('domicilios_v3'); + final raw = prefs.getString('domicilios_v4'); - if (raw != null && raw.isNotEmpty) { - final list = jsonDecode(raw) as List; - return list.map((e) => Domicilio.fromJson(Map.from(e))).toList(); - } + if (raw == null || raw.isEmpty) return []; - final principal = prefs.getString('domicilio') ?? ''; - final colonia = prefs.getString('colonia') ?? ''; - final negocio = prefs.getString('negocio') ?? ''; - final segundaCasa = prefs.getString('segundaCasa') ?? ''; - final otro = prefs.getString('otroDomicilio') ?? ''; - - final domicilios = []; - - if (principal.trim().isNotEmpty) { - domicilios.add(Domicilio(tipo: 'Casa principal', direccion: principal, colonia: colonia)); - } - if (negocio.trim().isNotEmpty) { - domicilios.add(Domicilio(tipo: 'Negocio', direccion: negocio, colonia: '')); - } - if (segundaCasa.trim().isNotEmpty) { - domicilios.add(Domicilio(tipo: 'Segunda casa', direccion: segundaCasa, colonia: '')); - } - if (otro.trim().isNotEmpty) { - domicilios.add(Domicilio(tipo: 'Otro domicilio', direccion: otro, colonia: '')); - } - - return domicilios; + final list = jsonDecode(raw) as List; + return list.map((e) => Domicilio.fromJson(Map.from(e))).toList(); } static Future guardarDomicilios(List domicilios) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString( - 'domicilios_v3', + 'domicilios_v4', jsonEncode(domicilios.map((e) => e.toJson()).toList()), ); } @@ -303,7 +474,9 @@ class Repo { } } -// ======================= ESTILO ======================= +// ======================================================= +// ESTILO +// ======================================================= class AppColors { static const green = Color(0xFF2E7D32); @@ -335,15 +508,15 @@ class RecolectorApp extends StatelessWidget { fontWeight: FontWeight.w900, ), ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)), ), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), ), home: const LoginPage(), ); @@ -390,7 +563,9 @@ class AppCard extends StatelessWidget { } } -// ======================= LOGIN ======================= +// ======================================================= +// LOGIN +// ======================================================= class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -454,7 +629,7 @@ class _LoginPageState extends State { ), const SizedBox(height: 8), const Text( - 'Recolección privada, horarios claros y seguimiento sin exponer ubicación exacta.', + 'Horarios claros, alertas privadas y educación para separar residuos.', textAlign: TextAlign.center, style: TextStyle(fontSize: 17, height: 1.35), ), @@ -502,7 +677,9 @@ class _LoginPageState extends State { } } -// ======================= HOME ======================= +// ======================================================= +// HOME +// ======================================================= class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -559,6 +736,8 @@ class _HomePageState extends State { Widget build(BuildContext context) { final principal = domicilios.isEmpty ? null : domicilios.first; final ruta = Repo.rutaDe(principal); + final horario = Repo.horarioDe(principal); + final colonia = Repo.coloniaDe(principal); return Scaffold( appBar: AppBar( @@ -582,15 +761,15 @@ class _HomePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Próxima recolección', style: TextStyle(fontWeight: FontWeight.w700)), + const Text('Ruta asignada', style: TextStyle(fontWeight: FontWeight.w700)), Text( - '${ruta.proxima} · ${ruta.horario}', - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w900), + ruta.routeId, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w900), ), Text( principal == null - ? 'Registra tu domicilio para personalizar la ruta.' - : 'Zona ${ruta.zona} · ${principal.tipo}', + ? 'Registra tu domicilio para asignar ruta.' + : '${colonia?.colonia ?? 'Colonia no validada'} · $horario', ), ], ), @@ -618,13 +797,10 @@ class _HomePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.star, color: Colors.amber), + const Icon(Icons.local_shipping, color: AppColors.green), const SizedBox(height: 6), - const Text('Última calificación', style: TextStyle(fontWeight: FontWeight.w800)), - Text( - servicios.isEmpty ? '—' : '${servicios.first.estrellas}/5', - style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), - ), + const Text('Camión', style: TextStyle(fontWeight: FontWeight.w800)), + Text('${ruta.truckId}', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900)), ], ), ), @@ -635,7 +811,7 @@ class _HomePageState extends State { menuCard( icon: Icons.person, title: 'Datos personales', - subtitle: 'Registra información, domicilios, días y horarios.', + subtitle: 'Registra tus datos, colonia y domicilio con mapa.', onTap: () async { await Navigator.push(context, MaterialPageRoute(builder: (_) => const DatosPage())); cargar(); @@ -644,7 +820,7 @@ class _HomePageState extends State { menuCard( icon: Icons.local_shipping, title: 'Seguimiento de basura', - subtitle: 'Selecciona domicilio y simula eventos del camión.', + subtitle: 'Simula eventos oficiales por positionId sin mostrar mapa público.', onTap: () async { await Navigator.push(context, MaterialPageRoute(builder: (_) => const SeguimientoPage())); cargar(); @@ -653,7 +829,7 @@ class _HomePageState extends State { menuCard( icon: Icons.recycling, title: 'Guía para la separación', - subtitle: 'Clasifica residuos orgánicos, reciclables y especiales.', + subtitle: 'Orgánicos, reciclables, sanitarios y especiales.', onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) => const GuiaPage())); }, @@ -661,21 +837,11 @@ class _HomePageState extends State { menuCard( icon: Icons.feedback, title: 'Buzón de sugerencias', - subtitle: 'Envía quejas, reportes o comentarios del servicio.', + subtitle: 'Reporta incidencias o califica el servicio.', onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) => const BuzonPage())); }, ), - if (servicios.isNotEmpty) ...[ - const SectionTitle('Último servicio'), - AppCard( - child: ListTile( - leading: const Icon(Icons.history, color: AppColors.green), - title: Text(servicios.first.domicilio), - subtitle: Text('Calificación ${servicios.first.estrellas}/5 · ${servicios.first.fecha}'), - ), - ), - ], ], ), ), @@ -683,7 +849,1103 @@ class _HomePageState extends State { } } -// ======================= BUZÓN ======================= +// ======================================================= +// MAPA PARA SELECCIONAR DOMICILIO +// ======================================================= + +class MapPickerPage extends StatefulWidget { + final double? initialLat; + final double? initialLng; + + const MapPickerPage({ + super.key, + this.initialLat, + this.initialLng, + }); + + @override + State createState() => _MapPickerPageState(); +} + +class _MapPickerPageState extends State { + late LatLng selected; + + @override + void initState() { + super.initState(); + selected = LatLng(widget.initialLat ?? 20.5210, widget.initialLng ?? -100.8210); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Seleccionar domicilio'), + ), + body: Stack( + children: [ + FlutterMap( + options: MapOptions( + initialCenter: selected, + initialZoom: 14, + onTap: (tapPosition, point) { + setState(() => selected = point); + }, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.recolector_app', + ), + MarkerLayer( + markers: [ + Marker( + point: selected, + width: 80, + height: 80, + child: const Icon( + Icons.location_pin, + color: AppColors.red, + size: 48, + ), + ), + ], + ), + ], + ), + Positioned( + left: 16, + right: 16, + bottom: 18, + child: AppCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Toca el mapa para colocar tu domicilio', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + Text( + 'Lat: ${selected.latitude.toStringAsFixed(6)} · Lng: ${selected.longitude.toStringAsFixed(6)}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + SizedBox( + height: 52, + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + Navigator.pop(context, selected); + }, + icon: const Icon(Icons.check), + label: const Text('Usar esta ubicación'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ======================================================= +// DATOS +// ======================================================= + +class DatosPage extends StatefulWidget { + const DatosPage({super.key}); + + @override + State createState() => _DatosPageState(); +} + +class _DatosPageState extends State with SingleTickerProviderStateMixin { + late TabController tab; + + final nombre = TextEditingController(); + final telefono = TextEditingController(); + final correo = TextEditingController(); + final rfc = TextEditingController(); + + final direccionPrincipal = TextEditingController(); + final coloniaPrincipal = TextEditingController(); + + final direccionExtra = TextEditingController(); + final coloniaExtra = TextEditingController(); + + String tipoExtra = 'Negocio'; + String resultado = ''; + List domicilios = []; + + double? latPrincipal; + double? lngPrincipal; + double? latExtra; + double? lngExtra; + + @override + void initState() { + super.initState(); + tab = TabController(length: 2, vsync: this); + cargar(); + } + + Future cargar() async { + final usuario = await Repo.cargarUsuario(); + final ds = await Repo.cargarDomicilios(); + + nombre.text = usuario['nombre'] ?? ''; + telefono.text = usuario['telefono'] ?? ''; + correo.text = usuario['correo'] ?? ''; + rfc.text = usuario['rfc'] ?? ''; + + if (ds.isNotEmpty) { + final principal = ds.firstWhere( + (d) => d.tipo == 'Casa principal', + orElse: () => ds.first, + ); + direccionPrincipal.text = principal.direccion; + coloniaPrincipal.text = principal.colonia; + latPrincipal = principal.lat; + lngPrincipal = principal.lng; + } + + if (!mounted) return; + + setState(() { + domicilios = ds; + }); + } + + Future abrirMapaPrincipal() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MapPickerPage( + initialLat: latPrincipal, + initialLng: lngPrincipal, + ), + ), + ); + + if (result == null) return; + + setState(() { + latPrincipal = result.latitude; + lngPrincipal = result.longitude; + }); + } + + Future abrirMapaExtra() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MapPickerPage( + initialLat: latExtra, + initialLng: lngExtra, + ), + ), + ); + + if (result == null) return; + + setState(() { + latExtra = result.latitude; + lngExtra = result.longitude; + }); + } + + Future guardarRegistro() async { + if (nombre.text.trim().isEmpty || telefono.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nombre y teléfono son obligatorios')), + ); + return; + } + + await Repo.guardarUsuario( + nombre: nombre.text.trim(), + telefono: telefono.text.trim(), + correo: correo.text.trim(), + rfc: rfc.text.trim(), + ); + + final lista = [...domicilios]; + + if (direccionPrincipal.text.trim().isNotEmpty) { + final principal = Domicilio( + tipo: 'Casa principal', + direccion: direccionPrincipal.text.trim(), + colonia: coloniaPrincipal.text.trim(), + lat: latPrincipal, + lng: lngPrincipal, + ); + + final index = lista.indexWhere((d) => d.tipo == 'Casa principal'); + if (index >= 0) { + lista[index] = principal; + } else { + lista.insert(0, principal); + } + } + + await Repo.guardarDomicilios(lista); + + if (!mounted) return; + + setState(() { + domicilios = lista; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Registro guardado')), + ); + } + + Future agregarDomicilio() async { + if (direccionExtra.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Escribe el domicilio a agregar')), + ); + return; + } + + final nuevo = Domicilio( + tipo: tipoExtra, + direccion: direccionExtra.text.trim(), + colonia: coloniaExtra.text.trim(), + lat: latExtra, + lng: lngExtra, + ); + + final lista = [...domicilios, nuevo]; + await Repo.guardarDomicilios(lista); + + if (!mounted) return; + + setState(() { + domicilios = lista; + direccionExtra.clear(); + coloniaExtra.clear(); + latExtra = null; + lngExtra = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$tipoExtra agregado')), + ); + } + + Future eliminarDomicilio(int index) async { + final lista = [...domicilios]..removeAt(index); + await Repo.guardarDomicilios(lista); + + if (!mounted) return; + + setState(() { + domicilios = lista; + }); + } + + void simularHorarios() { + final lista = []; + + if (domicilios.isNotEmpty) { + lista.addAll(domicilios); + } else if (direccionPrincipal.text.trim().isNotEmpty) { + lista.add( + Domicilio( + tipo: 'Casa principal', + direccion: direccionPrincipal.text.trim(), + colonia: coloniaPrincipal.text.trim(), + lat: latPrincipal, + lng: lngPrincipal, + ), + ); + } + + if (lista.isEmpty) { + setState(() { + resultado = 'Registra al menos un domicilio para desplegar ruta y horario.'; + }); + return; + } + + final buffer = StringBuffer(); + + for (final d in lista) { + final ruta = Repo.rutaDe(d); + final colonia = Repo.coloniaDe(d); + final horario = Repo.horarioDe(d); + + buffer.writeln(d.etiqueta); + buffer.writeln('Colonia validada: ${colonia?.colonia ?? 'No validada, se asigna ruta demo'}'); + buffer.writeln('Ruta: ${ruta.routeId} · ${ruta.name}'); + buffer.writeln('Camión: ${ruta.truckId}'); + buffer.writeln('Horario: $horario'); + if (d.lat != null && d.lng != null) { + buffer.writeln('Ubicación: ${d.lat!.toStringAsFixed(5)}, ${d.lng!.toStringAsFixed(5)}'); + } + buffer.writeln(''); + } + + setState(() { + resultado = buffer.toString().trim(); + }); + } + + Widget campo({ + required String label, + required TextEditingController controller, + IconData? icon, + TextInputType? keyboard, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextField( + controller: controller, + keyboardType: keyboard, + decoration: InputDecoration( + labelText: label, + prefixIcon: icon == null ? null : Icon(icon), + ), + ), + ); + } + + Widget selectorMapa({ + required VoidCallback onPressed, + required double? lat, + required double? lng, + }) { + return AppCard( + color: AppColors.softGreen, + child: Column( + children: [ + const Text( + 'Ubicación en mapa', + style: TextStyle(fontSize: 19, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + Text( + lat == null || lng == null + ? 'Aún no has seleccionado ubicación.' + : 'Lat: ${lat.toStringAsFixed(6)} · Lng: ${lng.toStringAsFixed(6)}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + SizedBox( + height: 52, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.map), + label: const Text('Abrir mapa'), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + tab.dispose(); + nombre.dispose(); + telefono.dispose(); + correo.dispose(); + rfc.dispose(); + direccionPrincipal.dispose(); + coloniaPrincipal.dispose(); + direccionExtra.dispose(); + coloniaExtra.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colonias = Repo.colonias(); + + return Scaffold( + appBar: AppBar( + title: const Text('Datos personales'), + bottom: TabBar( + controller: tab, + tabs: const [ + Tab(text: 'Registro'), + Tab(text: 'Domicilios'), + ], + ), + ), + body: TabBarView( + controller: tab, + children: [ + ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionTitle('Información del usuario'), + campo(label: 'Nombre completo', controller: nombre, icon: Icons.person), + campo(label: 'Teléfono', controller: telefono, icon: Icons.phone, keyboard: TextInputType.phone), + campo(label: 'Correo electrónico', controller: correo, icon: Icons.email, keyboard: TextInputType.emailAddress), + campo(label: 'RFC opcional', controller: rfc, icon: Icons.badge), + const SectionTitle('Casa principal'), + campo(label: 'Domicilio principal', controller: direccionPrincipal, icon: Icons.home), + DropdownButtonFormField( + value: coloniaPrincipal.text.trim().isEmpty ? null : coloniaPrincipal.text.trim(), + decoration: const InputDecoration( + labelText: 'Colonia', + prefixIcon: Icon(Icons.location_city), + ), + items: colonias.map((c) { + return DropdownMenuItem( + value: c.colonia, + child: Text(c.colonia), + ); + }).toList(), + onChanged: (value) { + coloniaPrincipal.text = value ?? ''; + }, + ), + const SizedBox(height: 14), + selectorMapa( + onPressed: abrirMapaPrincipal, + lat: latPrincipal, + lng: lngPrincipal, + ), + const SizedBox(height: 10), + SizedBox( + height: 54, + child: FilledButton.icon( + onPressed: guardarRegistro, + icon: const Icon(Icons.save), + label: const Text('Guardar registro', style: TextStyle(fontSize: 17)), + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 54, + child: OutlinedButton.icon( + onPressed: simularHorarios, + icon: const Icon(Icons.calendar_month), + label: const Text('Ver ruta asignada', style: TextStyle(fontSize: 17)), + ), + ), + if (resultado.isNotEmpty) ...[ + const SizedBox(height: 16), + AppCard( + color: AppColors.softGreen, + child: Text(resultado, style: const TextStyle(fontSize: 16.5, height: 1.45)), + ), + ], + ], + ), + ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionTitle( + 'Más domicilios', + subtitle: 'Agrega negocios, segunda casa u otros puntos de recolección.', + ), + DropdownButtonFormField( + value: tipoExtra, + decoration: const InputDecoration( + labelText: 'Tipo de domicilio', + prefixIcon: Icon(Icons.category), + ), + items: const [ + DropdownMenuItem(value: 'Negocio', child: Text('Negocio')), + DropdownMenuItem(value: 'Segunda casa', child: Text('Segunda casa')), + DropdownMenuItem(value: 'Otro domicilio', child: Text('Otro domicilio')), + ], + onChanged: (value) => setState(() => tipoExtra = value ?? 'Negocio'), + ), + const SizedBox(height: 14), + campo(label: 'Dirección', controller: direccionExtra, icon: Icons.add_location_alt), + DropdownButtonFormField( + value: coloniaExtra.text.trim().isEmpty ? null : coloniaExtra.text.trim(), + decoration: const InputDecoration( + labelText: 'Colonia', + prefixIcon: Icon(Icons.location_city), + ), + items: colonias.map((c) { + return DropdownMenuItem( + value: c.colonia, + child: Text(c.colonia), + ); + }).toList(), + onChanged: (value) { + coloniaExtra.text = value ?? ''; + }, + ), + const SizedBox(height: 14), + selectorMapa( + onPressed: abrirMapaExtra, + lat: latExtra, + lng: lngExtra, + ), + const SizedBox(height: 10), + SizedBox( + height: 54, + child: FilledButton.icon( + onPressed: agregarDomicilio, + icon: const Icon(Icons.add), + label: const Text('Agregar domicilio', style: TextStyle(fontSize: 17)), + ), + ), + const SectionTitle('Domicilios registrados'), + if (domicilios.isEmpty) + const AppCard(child: Text('Todavía no hay domicilios guardados.')) + else + ...List.generate(domicilios.length, (i) { + final d = domicilios[i]; + final ruta = Repo.rutaDe(d); + final horario = Repo.horarioDe(d); + + return Card( + child: ListTile( + leading: const Icon(Icons.home_work, color: AppColors.green), + title: Text(d.etiqueta, style: const TextStyle(fontWeight: FontWeight.w800)), + subtitle: Text('${ruta.routeId} · $horario'), + trailing: IconButton( + onPressed: () => eliminarDomicilio(i), + icon: const Icon(Icons.delete_outline, color: AppColors.red), + ), + ), + ); + }), + ], + ), + ], + ), + ); + } +} + +// ======================================================= +// SEGUIMIENTO +// ======================================================= + +enum EventoCamion { normal, retraso, averia } + +class SeguimientoPage extends StatefulWidget { + const SeguimientoPage({super.key}); + + @override + State createState() => _SeguimientoPageState(); +} + +class _SeguimientoPageState extends State { + Timer? timer; + List domicilios = []; + Domicilio? seleccionado; + + int paso = 0; + int estrellas = 0; + EventoCamion evento = EventoCamion.normal; + + @override + void initState() { + super.initState(); + cargar(); + } + + Future cargar() async { + final lista = await Repo.cargarDomicilios(); + + if (!mounted) return; + + setState(() { + domicilios = lista; + seleccionado = lista.isEmpty ? null : lista.first; + }); + } + + RutaOficial get ruta => Repo.rutaDe(seleccionado); + String get horario => Repo.horarioDe(seleccionado); + List get posiciones => ruta.positions; + + int get positionIdActual { + if (posiciones.isEmpty) return 0; + return posiciones[paso.clamp(0, posiciones.length - 1)].positionId; + } + + bool get finalizado { + if (posiciones.isEmpty) return false; + return paso >= posiciones.length - 1; + } + + double get progreso { + if (posiciones.isEmpty) return 0; + return (paso + 1) / posiciones.length; + } + + String get tituloEstado { + if (evento == EventoCamion.retraso) return 'Retraso técnico: 25 minutos'; + if (evento == EventoCamion.averia) return 'Camión averiado'; + + if (positionIdActual == 1) return 'Recolección programada'; + if (positionIdActual == 2) return 'Ruta iniciada'; + if (positionIdActual == 4) return 'El camión pasará en menos de 15 minutos'; + if (positionIdActual == 8) return 'Servicio finalizado'; + + return 'Camión en ruta asignada'; + } + + String get mensajeEstado { + if (seleccionado == null) { + return 'Primero registra un domicilio en Datos personales.'; + } + + if (evento == EventoCamion.retraso) { + return 'Conserva tus residuos en casa hasta que se reactive el servicio.'; + } + + if (evento == EventoCamion.averia) { + return 'Se enviará una notificación cuando haya unidad de reemplazo.'; + } + + if (positionIdActual == 1) { + return 'Tu ruta asignada es ${ruta.routeId}. Ventana estimada: $horario.'; + } + + if (positionIdActual == 2) { + return 'El camión salió del relleno sanitario rumbo a tu sector.'; + } + + if (positionIdActual == 4) { + return 'Es momento de preparar tus residuos. No persigas la unidad ni salgas antes del horario.'; + } + + if (positionIdActual == 8) { + return 'Servicio concluido. Ya puedes calificar de 1 a 5 estrellas.'; + } + + return 'Se muestra solo el avance operativo de tu ruta, sin mapa público ni rastreo en tiempo real.'; + } + + void iniciar() { + if (seleccionado == null) { + aviso('Sin domicilio', 'Registra un domicilio para iniciar el seguimiento.'); + return; + } + + timer?.cancel(); + + setState(() { + paso = 0; + estrellas = 0; + evento = EventoCamion.normal; + }); + + aviso('Seguimiento iniciado', seleccionado!.etiqueta); + + timer = Timer.periodic(const Duration(seconds: 4), (t) { + if (paso >= posiciones.length - 1) { + t.cancel(); + return; + } + + setState(() => paso++); + + dispararNotificacionSegunPositionId(); + + if (finalizado) { + t.cancel(); + } + }); + } + + void dispararNotificacionSegunPositionId() { + String? trigger; + + if (positionIdActual == 2) { + trigger = 'ROUTE_START'; + } else if (positionIdActual == 4) { + trigger = 'TRUCK_PROXIMITY'; + } else if (positionIdActual == 8) { + trigger = 'ROUTE_COMPLETED'; + } + + if (trigger == null) return; + + final notificacion = Repo.notificacionPorEvento(trigger); + if (notificacion == null) return; + + aviso(notificacion.title, notificacion.body); + } + + void simularRetraso() { + if (seleccionado == null) { + aviso('Sin domicilio', 'Selecciona un domicilio primero.'); + return; + } + + timer?.cancel(); + + setState(() { + evento = EventoCamion.retraso; + }); + + aviso('Retraso técnico', 'Tiempo estimado adicional: 25 minutos.'); + } + + void simularAveria() { + if (seleccionado == null) { + aviso('Sin domicilio', 'Selecciona un domicilio primero.'); + return; + } + + timer?.cancel(); + + setState(() { + evento = EventoCamion.averia; + }); + + aviso('Notificación enviada', 'El camión se averió. Se notificará al celular registrado.'); + } + + Future finalizar() async { + if (seleccionado == null || posiciones.isEmpty) return; + + timer?.cancel(); + setState(() => paso = posiciones.length - 1); + + dispararNotificacionSegunPositionId(); + } + + Future guardarCalificacion(int valor) async { + if (!finalizado || seleccionado == null) return; + + setState(() => estrellas = valor); + + final now = DateTime.now(); + final fecha = '${now.day.toString().padLeft(2, '0')}/${now.month.toString().padLeft(2, '0')}/${now.year}'; + + await Repo.guardarServicio( + Servicio( + domicilio: seleccionado!.etiqueta, + estrellas: valor, + fecha: fecha, + ), + ); + + if (!mounted) return; + aviso('Gracias', 'Calificación guardada: $valor/5'); + } + + void reiniciar() { + timer?.cancel(); + + setState(() { + paso = 0; + estrellas = 0; + evento = EventoCamion.normal; + }); + } + + void aviso(String titulo, String mensaje) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$titulo\n$mensaje'), + duration: const Duration(seconds: 4), + ), + ); + } + + Widget pasoItem(int index) { + final position = posiciones[index]; + final completado = index < paso; + final actual = index == paso; + + Color color = Colors.grey.shade300; + IconData icon = Icons.circle; + + if (completado) { + color = AppColors.green; + icon = Icons.check; + } + + if (actual) { + color = AppColors.green; + icon = Icons.local_shipping; + + if (evento == EventoCamion.retraso) { + color = AppColors.orange; + icon = Icons.timer; + } + + if (evento == EventoCamion.averia) { + color = AppColors.red; + icon = Icons.warning; + } + } + + String texto = 'Punto operativo ${position.positionId}'; + + if (position.positionId == 1) texto = 'Inicio en relleno sanitario'; + if (position.positionId == 2) texto = 'Ruta iniciada'; + if (position.positionId == 4) texto = 'Camión cercano a tu zona'; + if (position.positionId == 5) texto = 'Recolección en zona asignada'; + if (position.positionId == 8) texto = 'Retorno y servicio finalizado'; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: color, + child: Icon(icon, color: Colors.white, size: 18), + ), + if (index != posiciones.length - 1) + Container( + width: 4, + height: 44, + color: completado ? AppColors.green : Colors.grey.shade300, + ), + ], + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 5), + child: Text( + texto, + style: TextStyle( + fontSize: actual ? 19 : 17, + fontWeight: actual ? FontWeight.w900 : FontWeight.w500, + color: actual ? color : Colors.black87, + ), + ), + ), + ), + ], + ); + } + + Widget rating() { + return AppCard( + child: Column( + children: [ + const Text('Califica el servicio', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900)), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (index) { + final valor = index + 1; + + return IconButton( + iconSize: 40, + onPressed: finalizado ? () => guardarCalificacion(valor) : null, + icon: Icon( + index < estrellas ? Icons.star : Icons.star_border, + color: finalizado ? Colors.amber : Colors.grey, + ), + ); + }), + ), + Text( + finalizado ? 'Selecciona de 1 a 5 estrellas.' : 'Disponible al terminar el seguimiento.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sinDomicilios = domicilios.isEmpty; + final colonia = Repo.coloniaDe(seleccionado); + + return Scaffold( + appBar: AppBar(title: const Text('Seguimiento de basura')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + AppCard( + color: AppColors.softGreen, + child: Column( + children: [ + const Text('Domicilio del seguimiento', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900)), + const SizedBox(height: 12), + if (sinDomicilios) + Column( + children: [ + const Text( + 'No tienes domicilios registrados.', + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.red, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const DatosPage())); + cargar(); + }, + icon: const Icon(Icons.add_home), + label: const Text('Registrar domicilio'), + ), + ], + ) + else + DropdownButtonFormField( + value: seleccionado, + decoration: const InputDecoration( + labelText: 'Selecciona domicilio', + prefixIcon: Icon(Icons.home), + fillColor: Colors.white, + ), + items: domicilios.map((d) { + return DropdownMenuItem( + value: d, + child: Text(d.etiqueta, overflow: TextOverflow.ellipsis), + ); + }).toList(), + onChanged: (value) { + timer?.cancel(); + + setState(() { + seleccionado = value; + paso = 0; + estrellas = 0; + evento = EventoCamion.normal; + }); + }, + ), + const SizedBox(height: 16), + Text( + ruta.routeId, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 6), + Text( + '${ruta.name}\nCamión ${ruta.truckId} · ${colonia?.colonia ?? 'colonia no validada'}\n$horario', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.5, height: 1.35), + ), + ], + ), + ), + const SizedBox(height: 12), + AppCard( + child: Column( + children: [ + const Icon(Icons.local_shipping, size: 68, color: AppColors.green), + const SizedBox(height: 8), + Text( + tituloEstado, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + Text( + mensajeEstado, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16.5, height: 1.35), + ), + const SizedBox(height: 16), + LinearProgressIndicator(value: progreso, minHeight: 12), + const SizedBox(height: 8), + Text( + 'positionId actual: $positionIdActual', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ), + const SectionTitle('Estado privado del servicio'), + ...List.generate(posiciones.length, pasoItem), + const SizedBox(height: 12), + SizedBox( + height: 54, + child: FilledButton.icon( + onPressed: iniciar, + icon: const Icon(Icons.play_arrow), + label: const Text('Iniciar simulación', style: TextStyle(fontSize: 17)), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 54, + child: ElevatedButton.icon( + onPressed: simularRetraso, + icon: const Icon(Icons.timer), + label: const Text('Retraso'), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 54, + child: ElevatedButton.icon( + onPressed: simularAveria, + icon: const Icon(Icons.warning), + label: const Text('Avería'), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 54, + child: OutlinedButton.icon( + onPressed: finalizar, + icon: const Icon(Icons.check_circle), + label: const Text('Finalizar'), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 54, + child: OutlinedButton.icon( + onPressed: reiniciar, + icon: const Icon(Icons.restart_alt), + label: const Text('Reiniciar'), + ), + ), + ), + ], + ), + const SizedBox(height: 18), + rating(), + const SizedBox(height: 16), + const Text( + 'Privacidad: no se muestra mapa con el camión moviéndose en tiempo real. Solo se muestran eventos operativos de la ruta asignada a tu domicilio.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black54), + ), + ], + ), + ); + } +} + +// ======================================================= +// BUZÓN +// ======================================================= class BuzonPage extends StatefulWidget { const BuzonPage({super.key}); @@ -795,10 +2057,7 @@ class _BuzonPageState extends State { child: FilledButton.icon( onPressed: enviar, icon: const Icon(Icons.send), - label: const Text( - 'Enviar reporte', - style: TextStyle(fontSize: 17), - ), + label: const Text('Enviar reporte', style: TextStyle(fontSize: 17)), ), ), const SizedBox(height: 10), @@ -807,10 +2066,7 @@ class _BuzonPageState extends State { child: OutlinedButton.icon( onPressed: limpiar, icon: const Icon(Icons.cleaning_services), - label: const Text( - 'Limpiar formulario', - style: TextStyle(fontSize: 17), - ), + label: const Text('Limpiar formulario', style: TextStyle(fontSize: 17)), ), ), ], @@ -819,815 +2075,8 @@ class _BuzonPageState extends State { } } -// ======================= DATOS ======================= - -class DatosPage extends StatefulWidget { - const DatosPage({super.key}); - - @override - State createState() => _DatosPageState(); -} - -class _DatosPageState extends State with SingleTickerProviderStateMixin { - late TabController tab; - - final nombre = TextEditingController(); - final telefono = TextEditingController(); - final correo = TextEditingController(); - final rfc = TextEditingController(); - - final direccionPrincipal = TextEditingController(); - final coloniaPrincipal = TextEditingController(); - - final direccionExtra = TextEditingController(); - final coloniaExtra = TextEditingController(); - - String tipoExtra = 'Negocio'; - String resultado = ''; - List domicilios = []; - - @override - void initState() { - super.initState(); - tab = TabController(length: 2, vsync: this); - cargar(); - } - - Future cargar() async { - final usuario = await Repo.cargarUsuario(); - final ds = await Repo.cargarDomicilios(); - - nombre.text = usuario['nombre'] ?? ''; - telefono.text = usuario['telefono'] ?? ''; - correo.text = usuario['correo'] ?? ''; - rfc.text = usuario['rfc'] ?? ''; - - if (ds.isNotEmpty) { - final principal = ds.firstWhere( - (d) => d.tipo == 'Casa principal', - orElse: () => ds.first, - ); - direccionPrincipal.text = principal.direccion; - coloniaPrincipal.text = principal.colonia; - } - - if (!mounted) return; - - setState(() { - domicilios = ds; - }); - } - - Future guardarRegistro() async { - if (nombre.text.trim().isEmpty || telefono.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Nombre y teléfono son obligatorios')), - ); - return; - } - - await Repo.guardarUsuario( - nombre: nombre.text.trim(), - telefono: telefono.text.trim(), - correo: correo.text.trim(), - rfc: rfc.text.trim(), - ); - - final lista = [...domicilios]; - - if (direccionPrincipal.text.trim().isNotEmpty) { - final principal = Domicilio( - tipo: 'Casa principal', - direccion: direccionPrincipal.text.trim(), - colonia: coloniaPrincipal.text.trim(), - ); - - final index = lista.indexWhere((d) => d.tipo == 'Casa principal'); - if (index >= 0) { - lista[index] = principal; - } else { - lista.insert(0, principal); - } - } - - await Repo.guardarDomicilios(lista); - - if (!mounted) return; - - setState(() { - domicilios = lista; - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Registro guardado')), - ); - } - - Future agregarDomicilio() async { - if (direccionExtra.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Escribe el domicilio a agregar')), - ); - return; - } - - final nuevo = Domicilio( - tipo: tipoExtra, - direccion: direccionExtra.text.trim(), - colonia: coloniaExtra.text.trim(), - ); - - final lista = [...domicilios, nuevo]; - await Repo.guardarDomicilios(lista); - - if (!mounted) return; - - setState(() { - domicilios = lista; - direccionExtra.clear(); - coloniaExtra.clear(); - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$tipoExtra agregado')), - ); - } - - Future eliminarDomicilio(int index) async { - final lista = [...domicilios]..removeAt(index); - await Repo.guardarDomicilios(lista); - - if (!mounted) return; - - setState(() { - domicilios = lista; - }); - } - - void simularHorarios() { - final lista = []; - - if (domicilios.isNotEmpty) { - lista.addAll(domicilios); - } else if (direccionPrincipal.text.trim().isNotEmpty) { - lista.add( - Domicilio( - tipo: 'Casa principal', - direccion: direccionPrincipal.text.trim(), - colonia: coloniaPrincipal.text.trim(), - ), - ); - } - - if (lista.isEmpty) { - setState(() { - resultado = 'Registra al menos un domicilio para desplegar días y horarios.'; - }); - return; - } - - final buffer = StringBuffer(); - - for (final d in lista) { - final ruta = Repo.rutaDe(d); - buffer.writeln(d.etiqueta); - buffer.writeln('Zona: ${ruta.zona}'); - buffer.writeln('Días: ${ruta.diasTexto}'); - buffer.writeln('Horario: ${ruta.horario}'); - buffer.writeln('Próxima recolección: ${ruta.proxima}'); - buffer.writeln(''); - } - - setState(() { - resultado = buffer.toString().trim(); - }); - } - - Widget campo({ - required String label, - required TextEditingController controller, - IconData? icon, - TextInputType? keyboard, - }) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: TextField( - controller: controller, - keyboardType: keyboard, - decoration: InputDecoration( - labelText: label, - prefixIcon: icon == null ? null : Icon(icon), - ), - ), - ); - } - - @override - void dispose() { - tab.dispose(); - nombre.dispose(); - telefono.dispose(); - correo.dispose(); - rfc.dispose(); - direccionPrincipal.dispose(); - coloniaPrincipal.dispose(); - direccionExtra.dispose(); - coloniaExtra.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Datos personales'), - bottom: TabBar( - controller: tab, - tabs: const [ - Tab(text: 'Registro'), - Tab(text: 'Domicilios'), - ], - ), - ), - body: TabBarView( - controller: tab, - children: [ - ListView( - padding: const EdgeInsets.all(16), - children: [ - const SectionTitle('Información del usuario'), - campo(label: 'Nombre completo', controller: nombre, icon: Icons.person), - campo(label: 'Teléfono', controller: telefono, icon: Icons.phone, keyboard: TextInputType.phone), - campo(label: 'Correo electrónico', controller: correo, icon: Icons.email, keyboard: TextInputType.emailAddress), - campo(label: 'RFC (opcional)', controller: rfc, icon: Icons.badge), - const SectionTitle('Casa principal'), - campo(label: 'Domicilio principal', controller: direccionPrincipal, icon: Icons.home), - campo(label: 'Colonia', controller: coloniaPrincipal, icon: Icons.location_city), - SizedBox( - height: 54, - child: FilledButton.icon( - onPressed: guardarRegistro, - icon: const Icon(Icons.save), - label: const Text('Guardar registro', style: TextStyle(fontSize: 17)), - ), - ), - const SizedBox(height: 10), - SizedBox( - height: 54, - child: OutlinedButton.icon( - onPressed: simularHorarios, - icon: const Icon(Icons.calendar_month), - label: const Text('Simular días y horarios', style: TextStyle(fontSize: 17)), - ), - ), - if (resultado.isNotEmpty) ...[ - const SizedBox(height: 16), - AppCard( - color: AppColors.softGreen, - child: Text(resultado, style: const TextStyle(fontSize: 16.5, height: 1.45)), - ), - ], - ], - ), - ListView( - padding: const EdgeInsets.all(16), - children: [ - const SectionTitle( - 'Más domicilios', - subtitle: 'Agrega negocios, segunda casa u otros puntos de recolección.', - ), - DropdownButtonFormField( - value: tipoExtra, - decoration: const InputDecoration( - labelText: 'Tipo de domicilio', - prefixIcon: Icon(Icons.category), - ), - items: const [ - DropdownMenuItem(value: 'Negocio', child: Text('Negocio')), - DropdownMenuItem(value: 'Segunda casa', child: Text('Segunda casa')), - DropdownMenuItem(value: 'Otro domicilio', child: Text('Otro domicilio')), - ], - onChanged: (value) => setState(() => tipoExtra = value ?? 'Negocio'), - ), - const SizedBox(height: 14), - campo(label: 'Dirección', controller: direccionExtra, icon: Icons.add_location_alt), - campo(label: 'Colonia o referencia', controller: coloniaExtra, icon: Icons.location_city), - SizedBox( - height: 54, - child: FilledButton.icon( - onPressed: agregarDomicilio, - icon: const Icon(Icons.add), - label: const Text('Agregar domicilio', style: TextStyle(fontSize: 17)), - ), - ), - const SectionTitle('Domicilios registrados'), - if (domicilios.isEmpty) - const AppCard(child: Text('Todavía no hay domicilios guardados.')) - else - ...List.generate(domicilios.length, (i) { - final d = domicilios[i]; - final ruta = Repo.rutaDe(d); - - return Card( - child: ListTile( - leading: const Icon(Icons.home_work, color: AppColors.green), - title: Text(d.etiqueta, style: const TextStyle(fontWeight: FontWeight.w800)), - subtitle: Text('Zona ${ruta.zona} · ${ruta.diasTexto} · ${ruta.horario}'), - trailing: IconButton( - onPressed: () => eliminarDomicilio(i), - icon: const Icon(Icons.delete_outline, color: AppColors.red), - ), - ), - ); - }), - ], - ), - ], - ), - ); - } -} - -// ======================= SEGUIMIENTO ======================= - -enum EventoCamion { normal, retraso, averia } - -class SeguimientoPage extends StatefulWidget { - const SeguimientoPage({super.key}); - - @override - State createState() => _SeguimientoPageState(); -} - -class _SeguimientoPageState extends State { - Timer? timer; - List domicilios = []; - Domicilio? seleccionado; - - int paso = 0; - int estrellas = 0; - EventoCamion evento = EventoCamion.normal; - - final List pasosBase = const [ - 'Recolección programada', - 'Camión cercano', - 'Llegando a tu zona', - 'Recolección en proceso', - 'Servicio finalizado', - ]; - - @override - void initState() { - super.initState(); - cargar(); - } - - Future cargar() async { - final lista = await Repo.cargarDomicilios(); - - if (!mounted) return; - - setState(() { - domicilios = lista; - seleccionado = lista.isEmpty ? null : lista.first; - }); - } - - Ruta get ruta => Repo.rutaDe(seleccionado); - - List get pasos { - final lista = [...pasosBase]; - - if (evento == EventoCamion.retraso) { - lista.insert(2, 'Retraso técnico · Tiempo estimado: 25 minutos'); - } - - if (evento == EventoCamion.averia) { - lista.insert(2, 'Camión averiado · Notificación enviada al celular'); - } - - return lista; - } - - bool get finalizado => paso >= pasos.length - 1; - String get estado => pasos[paso.clamp(0, pasos.length - 1)]; - double get progreso => (paso + 1) / pasos.length; - - String get tituloEstado { - if (estado.startsWith('Retraso')) return 'Retraso técnico: 25 minutos'; - if (estado.startsWith('Camión averiado')) return 'Camión averiado'; - if (estado == 'Camión cercano') return 'El camión pasará en ${ruta.eta}'; - return estado; - } - - String get mensajeEstado { - if (seleccionado == null) { - return 'Primero registra un domicilio en Datos personales.'; - } - - if (estado.startsWith('Retraso')) { - return 'Conserva tus residuos en casa hasta que se reactive el servicio.'; - } - - if (estado.startsWith('Camión averiado')) { - return 'Se enviará una notificación al teléfono registrado cuando haya unidad de reemplazo.'; - } - - if (estado == 'Recolección programada') { - return 'Próxima recolección el día ${ruta.proxima}, de ${ruta.horario}.'; - } - - if (estado == 'Camión cercano' || estado == 'Llegando a tu zona') { - return ruta.consejo; - } - - if (estado == 'Recolección en proceso') { - return 'Mantén despejada la banqueta y evita perseguir al camión.'; - } - - return 'Servicio concluido. Ya puedes calificar de 1 a 5 estrellas.'; - } - - void iniciar() { - if (seleccionado == null) { - aviso('Sin domicilio', 'Registra un domicilio para iniciar el seguimiento.'); - return; - } - - timer?.cancel(); - - setState(() { - paso = 0; - estrellas = 0; - evento = EventoCamion.normal; - }); - - aviso('Seguimiento iniciado', seleccionado!.etiqueta); - - timer = Timer.periodic(const Duration(seconds: 4), (t) { - if (paso >= pasos.length - 1) { - t.cancel(); - return; - } - - setState(() => paso++); - - if (finalizado) { - t.cancel(); - aviso('Servicio finalizado', 'Ya puedes calificar el servicio.'); - } - }); - } - - void simularRetraso() { - if (seleccionado == null) { - aviso('Sin domicilio', 'Selecciona un domicilio primero.'); - return; - } - - timer?.cancel(); - - setState(() { - evento = EventoCamion.retraso; - paso = 2; - }); - - aviso('Retraso técnico', 'Tiempo estimado adicional: 25 minutos.'); - } - - void simularAveria() { - if (seleccionado == null) { - aviso('Sin domicilio', 'Selecciona un domicilio primero.'); - return; - } - - timer?.cancel(); - - setState(() { - evento = EventoCamion.averia; - paso = 2; - }); - - aviso('Notificación enviada', 'El camión se averió. Se notificará al celular registrado.'); - } - - Future finalizar() async { - if (seleccionado == null) return; - - timer?.cancel(); - setState(() => paso = pasos.length - 1); - - aviso('Servicio finalizado', 'La calificación ya está habilitada.'); - } - - Future guardarCalificacion(int valor) async { - if (!finalizado || seleccionado == null) return; - - setState(() => estrellas = valor); - - final now = DateTime.now(); - final fecha = '${now.day.toString().padLeft(2, '0')}/${now.month.toString().padLeft(2, '0')}/${now.year}'; - - await Repo.guardarServicio( - Servicio( - domicilio: seleccionado!.etiqueta, - estrellas: valor, - fecha: fecha, - ), - ); - - if (!mounted) return; - aviso('Gracias', 'Calificación guardada: $valor/5'); - } - - void reiniciar() { - timer?.cancel(); - - setState(() { - paso = 0; - estrellas = 0; - evento = EventoCamion.normal; - }); - } - - void aviso(String titulo, String mensaje) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$titulo\n$mensaje'), duration: const Duration(seconds: 4)), - ); - } - - Widget pasoItem(int index) { - final completado = index < paso; - final actual = index == paso; - - Color color = Colors.grey.shade300; - IconData icon = Icons.circle; - - if (completado) { - color = AppColors.green; - icon = Icons.check; - } - - if (actual) { - color = AppColors.green; - icon = Icons.local_shipping; - - if (pasos[index].startsWith('Retraso')) { - color = AppColors.orange; - icon = Icons.timer; - } - - if (pasos[index].startsWith('Camión averiado')) { - color = AppColors.red; - icon = Icons.warning; - } - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: color, - child: Icon(icon, color: Colors.white, size: 18), - ), - if (index != pasos.length - 1) - Container( - width: 4, - height: 44, - color: completado ? AppColors.green : Colors.grey.shade300, - ), - ], - ), - const SizedBox(width: 14), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 5), - child: Text( - pasos[index], - style: TextStyle( - fontSize: actual ? 19 : 17, - fontWeight: actual ? FontWeight.w900 : FontWeight.w500, - color: actual ? color : Colors.black87, - ), - ), - ), - ), - ], - ); - } - - Widget rating() { - return AppCard( - child: Column( - children: [ - const Text('Califica el servicio', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900)), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(5, (index) { - final valor = index + 1; - - return IconButton( - iconSize: 40, - onPressed: finalizado ? () => guardarCalificacion(valor) : null, - icon: Icon( - index < estrellas ? Icons.star : Icons.star_border, - color: finalizado ? Colors.amber : Colors.grey, - ), - ); - }), - ), - Text( - finalizado ? 'Selecciona de 1 a 5 estrellas.' : 'Disponible al terminar el seguimiento.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey.shade700), - ), - ], - ), - ); - } - - @override - void dispose() { - timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final sinDomicilios = domicilios.isEmpty; - - return Scaffold( - appBar: AppBar(title: const Text('Seguimiento de basura')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - AppCard( - color: AppColors.softGreen, - child: Column( - children: [ - const Text('Domicilio del seguimiento', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900)), - const SizedBox(height: 12), - if (sinDomicilios) - Column( - children: [ - const Text( - 'No tienes domicilios registrados.', - textAlign: TextAlign.center, - style: TextStyle(color: AppColors.red, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (_) => const DatosPage())); - cargar(); - }, - icon: const Icon(Icons.add_home), - label: const Text('Registrar domicilio'), - ), - ], - ) - else - DropdownButtonFormField( - value: seleccionado, - decoration: const InputDecoration( - labelText: 'Selecciona domicilio', - prefixIcon: Icon(Icons.home), - fillColor: Colors.white, - ), - items: domicilios.map((d) { - return DropdownMenuItem( - value: d, - child: Text(d.etiqueta, overflow: TextOverflow.ellipsis), - ); - }).toList(), - onChanged: (value) { - timer?.cancel(); - - setState(() { - seleccionado = value; - paso = 0; - estrellas = 0; - evento = EventoCamion.normal; - }); - }, - ), - const SizedBox(height: 16), - Text( - 'Próxima recolección el día ${ruta.proxima}', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w900), - ), - const SizedBox(height: 6), - Text( - 'Zona ${ruta.zona} · ${ruta.diasTexto}\nHorario estimado: ${ruta.horario}', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16.5, height: 1.35), - ), - ], - ), - ), - const SizedBox(height: 12), - AppCard( - child: Column( - children: [ - const Icon(Icons.local_shipping, size: 68, color: AppColors.green), - const SizedBox(height: 8), - Text( - tituloEstado, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w900), - ), - const SizedBox(height: 8), - Text( - mensajeEstado, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16.5, height: 1.35), - ), - const SizedBox(height: 16), - LinearProgressIndicator(value: progreso, minHeight: 12), - ], - ), - ), - const SectionTitle('Estado del servicio'), - ...List.generate(pasos.length, pasoItem), - const SizedBox(height: 12), - SizedBox( - height: 54, - child: FilledButton.icon( - onPressed: iniciar, - icon: const Icon(Icons.play_arrow), - label: const Text('Iniciar simulación', style: TextStyle(fontSize: 17)), - ), - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: SizedBox( - height: 54, - child: ElevatedButton.icon( - onPressed: simularRetraso, - icon: const Icon(Icons.timer), - label: const Text('Retraso'), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: SizedBox( - height: 54, - child: ElevatedButton.icon( - onPressed: simularAveria, - icon: const Icon(Icons.warning), - label: const Text('Avería'), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: SizedBox( - height: 54, - child: OutlinedButton.icon( - onPressed: finalizar, - icon: const Icon(Icons.check_circle), - label: const Text('Finalizar'), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: SizedBox( - height: 54, - child: OutlinedButton.icon( - onPressed: reiniciar, - icon: const Icon(Icons.restart_alt), - label: const Text('Reiniciar'), - ), - ), - ), - ], - ), - const SizedBox(height: 18), - rating(), - const SizedBox(height: 16), - const Text( - 'Privacidad: no se muestra mapa ni ubicación exacta del camión; solo eventos operativos y tiempos estimados.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.black54), - ), - ], - ), - ); - } -} - +// ======================================================= +// GUÍA // ======================= GUÍA ======================= class GuiaPage extends StatelessWidget { @@ -1638,23 +2087,15 @@ class GuiaPage extends StatelessWidget { String titulo, String ejemplo, String detalle, + String consejo, IconData icono, Color color, String imagen, ) { return Card( - child: ListTile( - contentPadding: const EdgeInsets.all(14), - leading: CircleAvatar( - backgroundColor: color.withOpacity(0.12), - child: Icon(icono, color: color), - ), - title: Text( - titulo, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w900), - ), - subtitle: Text(ejemplo), - trailing: const Icon(Icons.arrow_forward_ios, size: 18), + margin: const EdgeInsets.only(bottom: 16), + child: InkWell( + borderRadius: BorderRadius.circular(18), onTap: () { Navigator.push( context, @@ -1662,6 +2103,7 @@ class GuiaPage extends StatelessWidget { builder: (_) => DetalleGuiaPage( titulo: titulo, detalle: detalle, + consejo: consejo, icono: icono, color: color, imagen: imagen, @@ -1669,6 +2111,43 @@ class GuiaPage extends StatelessWidget { ), ); }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + child: Row( + children: [ + CircleAvatar( + radius: 34, + backgroundColor: color.withOpacity(0.12), + child: Icon(icono, color: color, size: 34), + ), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titulo, + style: const TextStyle( + fontSize: 27, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 6), + Text( + ejemplo, + style: TextStyle( + fontSize: 20, + color: Colors.grey.shade700, + height: 1.25, + ), + ), + ], + ), + ), + Icon(Icons.arrow_forward_ios, size: 26, color: Colors.grey.shade700), + ], + ), + ), ), ); } @@ -1680,17 +2159,19 @@ class GuiaPage extends StatelessWidget { title: const Text('Guía de separación'), ), body: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), children: [ const SectionTitle( 'Separa tu basura fácil', subtitle: 'Una guía rápida para reducir contaminación.', ), + const SizedBox(height: 10), basuraCard( context, 'Orgánicos', 'Comida, frutas, verduras', 'Son residuos naturales. Separarlos ayuda a hacer composta y evita malos olores.', + 'Usa bolsas o contenedores separados y saca tus residuos únicamente dentro del horario indicado por la app.', Icons.eco, AppColors.green, 'assets/images/Organico.jpeg', @@ -1699,7 +2180,8 @@ class GuiaPage extends StatelessWidget { context, 'Reciclables', 'Cartón, plástico, vidrio', - 'Pueden volver a usarse. Separarlos reduce basura y ayuda al medio ambiente.', + 'Son materiales que pueden volver a utilizarse si se entregan limpios y secos. Separarlos reduce la basura que llega al relleno sanitario.', + 'Aplasta botellas y cartón para ahorrar espacio. Evita mezclarlos con comida, grasa o líquidos.', Icons.recycling, Colors.blue, 'assets/images/Reciclables.jpeg', @@ -1708,7 +2190,8 @@ class GuiaPage extends StatelessWidget { context, 'Sanitarios', 'Papel higiénico, pañales', - 'Deben ir separados porque pueden tener bacterias y no se reciclan.', + 'Estos residuos pueden contener bacterias o fluidos. No deben mezclarse con reciclables ni orgánicos.', + 'Colócalos en una bolsa bien cerrada para evitar malos olores y contacto directo con otras personas.', Icons.delete, Colors.purple, 'assets/images/Sanitarios.jpeg', @@ -1717,7 +2200,8 @@ class GuiaPage extends StatelessWidget { context, 'Especiales', 'Pilas, electrónicos, aceite', - 'No deben mezclarse porque contaminan mucho y necesitan manejo especial.', + 'Estos residuos pueden contaminar agua, suelo o aire si se mezclan con basura común. Necesitan manejo especial.', + 'No los tires con la basura normal. Guárdalos aparte y llévalos a puntos de recolección autorizados.', Icons.warning, AppColors.orange, 'assets/images/Especiales.jpeg', @@ -1731,6 +2215,7 @@ class GuiaPage extends StatelessWidget { class DetalleGuiaPage extends StatelessWidget { final String titulo; final String detalle; + final String consejo; final IconData icono; final Color color; final String imagen; @@ -1739,6 +2224,7 @@ class DetalleGuiaPage extends StatelessWidget { super.key, required this.titulo, required this.detalle, + required this.consejo, required this.icono, required this.color, required this.imagen, @@ -1756,48 +2242,63 @@ class DetalleGuiaPage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( - radius: 48, + radius: 64, backgroundColor: color.withOpacity(0.12), - child: Icon(icono, size: 58, color: color), + child: Icon(icono, size: 76, color: color), ), - const SizedBox(height: 20), + const SizedBox(height: 28), Text( titulo, - style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w900), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w900, + ), ), - const SizedBox(height: 16), + const SizedBox(height: 24), Text( detalle, textAlign: TextAlign.center, - style: const TextStyle(fontSize: 20, height: 1.4), + style: const TextStyle( + fontSize: 26, + height: 1.45, + fontWeight: FontWeight.w500, + ), ), ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 18), AppCard( child: Column( children: [ const Text( 'Apoyo visual', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w900), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w900, + ), ), - const SizedBox(height: 12), + const SizedBox(height: 18), ClipRRect( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(18), child: Image.asset( imagen, width: double.infinity, - fit: BoxFit.contain, + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, errorBuilder: (context, error, stackTrace) { return Container( - height: 220, + height: 320, + width: double.infinity, alignment: Alignment.center, color: Colors.grey.shade200, - child: const Text( - 'No se encontró la imagen.\nRevisa assets/images/', + padding: const EdgeInsets.all(16), + child: Text( + 'No se encontró la imagen:\n$imagen\n\nRevisa assets/images/ y pubspec.yaml', textAlign: TextAlign.center, - style: TextStyle(fontSize: 18), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), ); }, @@ -1806,6 +2307,27 @@ class DetalleGuiaPage extends StatelessWidget { ], ), ), + const SizedBox(height: 18), + AppCard( + color: AppColors.softGreen, + child: Column( + children: [ + Icon(Icons.info_outline, size: 54, color: color), + const SizedBox(height: 12), + const Text( + 'Consejo práctico', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 26, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 10), + Text( + consejo, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20, height: 1.35), + ), + ], + ), + ), ], ), ); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 8ba7f5c..4d35ed9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f + url: "https://pub.dev" + source: hosted + version: "1.1.0" collection: dependency: transitive description: @@ -41,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -49,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" fake_async: dependency: transitive description: @@ -73,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -86,6 +142,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b" + url: "https://pub.dev" + source: hosted + version: "8.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -96,6 +160,62 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -128,6 +248,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -152,6 +280,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -160,6 +312,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -200,6 +376,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -256,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -309,6 +525,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -333,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: @@ -341,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index f6cb316..e4f518e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,8 +33,10 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - shared_preferences: ^2.2.3 + shared_preferences: ^2.5.5 cupertino_icons: ^1.0.8 + flutter_map: ^8.3.0 + latlong2: ^0.9.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..3ad69c6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)