diff --git a/.gitignore b/.gitignore index 28bede4..3bc27a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,12 @@ -<<<<<<< HEAD -# ---> Flutter # Miscellaneous *.class -*.lock *.log *.pyc *.swp +.DS_Store .buildlog/ .history - - # Flutter repo-specific /bin/cache/ /bin/internal/bootstrap.bat @@ -42,7 +38,7 @@ analysis_benchmark.json .packages .pub-preload-cache/ .pub/ -build/ +/build/ flutter_*.png linked_*.ds unlinked.ds @@ -58,6 +54,14 @@ unlinked_spec.ds **/android/**/GeneratedPluginRegistrant.java **/android/key.properties *.jks +*.keystore +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json +google-services.json +*.hprof # iOS/XCode related **/ios/**/*.mode1v3 @@ -119,85 +123,8 @@ app.*.symbols !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock -# ---> Android -# Gradle files -.gradle/ -build/ -# Local configuration file (sdk path, etc) -local.properties - -# Log/OS Files -*.log - -# Android Studio generated files and folders -captures/ -.externalNativeBuild/ -.cxx/ -*.apk -output.json - -# IntelliJ -*.iml +# IDE / project files .idea/ -misc.xml -deploymentTargetDropDown.xml -render.experimental.xml - -# Keystore files -*.jks -*.keystore - -# Google Services (e.g. APIs or Firebase) -google-services.json - -# Android Profiling -*.hprof - -======= -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related *.iml -*.ipr -*.iws -.idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release ->>>>>>> 8e51b9c (initial commit 2) diff --git a/README.md b/README.md index fa7fa69..0849554 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,73 @@ Una aplicacion para saber si el camion mas sercano es Organico o inorganico junt ======= # flutter_application_1 -A new Flutter project. +Aplicación móvil desarrollada en Flutter como una preview funcional para consulta de rutas de camiones, validación de usuarios y simulación de información local. El proyecto fue construido para mostrar una experiencia completa de usuario sin depender todavía de una base de datos real en producción. -## Getting Started +## Resumen del proyecto -This project is a starting point for a Flutter application. +La app ofrece un flujo completo de inicio de sesión, registro, captura de dirección y acceso a un tablero principal con mapa, calendario, avisos y guía de rutas. La información que se muestra no depende de un backend activo en el frontend: se trabajó con archivos JSON locales como fuente de datos principal para simular usuarios, rutas, eventos y configuraciones. -A few resources to get you started if this is your first Flutter project: +## Qué resuelve -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +- Inicio de sesión y registro con validación local. +- Captura de dirección del usuario para personalizar la experiencia. +- Visualización de rutas de recolección con información resumida y detalle por ruta. +- Calendario con eventos importantes y notas locales. +- Mapa orientado a la zona del usuario, con comportamiento adaptado por orientación de pantalla. +- Base de datos simulada con JSON para demostrar el flujo completo sin depender de infraestructura externa. +<<<<<<< HEAD For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. >>>>>>> 8e51b9c (initial commit 2) +======= +## Por qué se puede vender como preview + +Esta versión ya permite explicar la idea de negocio al usuario final o a un cliente potencial porque presenta una experiencia realista y navegable. Aunque aún faltan ajustes de producto y una capa de datos más robusta, la aplicación ya comunica claramente el valor principal: ayudar a visualizar rutas, horarios y eventos de recolección desde una interfaz móvil práctica. + +La propuesta se puede presentar como una preview comercial porque: + +- demuestra el flujo principal de usuario de punta a punta; +- usa datos locales para simular operación real; +- ya integra pantallas funcionales y una navegación completa; +- deja abierta la migración a una base de datos más fluida y escalable. + +## Pruebas y validación + +Durante el desarrollo se realizaron pruebas con JSON locales en `assets/json/` para simular la base de datos y validar la estructura de la app. Esto permitió probar login, registro, direcciones, rutas, calendario y guía de rutas sin depender de servicios externos. + +La idea de base de datos se mantiene pensada de forma más fluida para una siguiente etapa, donde los JSON pueden ser reemplazados por una fuente centralizada, sincronizada y más dinámica. + +## Tecnologías usadas + +- Flutter / Dart +- `flutter_map` +- `geolocator` +- `latlong2` +- `table_calendar` +- `shared_preferences` + +## Estructura funcional + +- `lib/screens/auth_screen.dart`: login y registro. +- `lib/screens/address_screen.dart`: captura de dirección local. +- `lib/screens/dashboard_screen.dart`: mapa, calendario, avisos y guía de rutas. +- `lib/services/local_seed_repository.dart`: carga de los JSON locales. +- `assets/json/`: datos de prueba que actúan como preview de base de datos. + +## Estado actual + +El proyecto ya funciona como una demo presentable y estable para mostrar la propuesta. Aun así, se considera una base en evolución: el siguiente paso natural es conectar una base de datos más flexible, reemplazar la simulación local por datos vivos y pulir algunos detalles de UX. + +## Cómo ejecutar + +```bash +flutter pub get +flutter run +``` + +## Nota final + +Este proyecto no pretende ser todavía el producto final, sino una preview sólida que demuestra la idea, la navegación y el valor de la solución. Es útil para presentaciones, validación temprana con usuarios y como base para una futura versión con backend y almacenamiento centralizado. +>>>>>>> c9e584a (Proyecto Flutter inicial) diff --git a/assets/json/calendario.json b/assets/json/calendario.json new file mode 100644 index 0000000..2ae5e17 --- /dev/null +++ b/assets/json/calendario.json @@ -0,0 +1,44 @@ +[ + { + "date": "2026-05-23", + "title": "Camión de orgánico", + "description": "Este día pasa el camión de orgánico por la ruta asignada.", + "category": "organic" + }, + { + "date": "2026-05-24", + "title": "Camión de inorgánico", + "description": "Este día pasará el camión inorgánico.", + "category": "inorganic" + }, + { + "date": "2026-05-25", + "title": "Evento vecinal", + "description": "El camión no puede pasar ese día por evento en la colonia.", + "category": "blocked" + }, + { + "date": "2026-05-27", + "title": "Ruta especial", + "description": "Día con recolección especial para residuos voluminosos.", + "category": "special" + }, + { + "date": "2026-05-29", + "title": "Recolección de orgánico", + "description": "El servicio orgánico pasa a partir de las 07:00.", + "category": "organic" + }, + { + "date": "2026-05-31", + "title": "Recolección de inorgánico", + "description": "El servicio inorgánico pasa a partir de las 08:00.", + "category": "inorganic" + }, + { + "date": "2026-06-02", + "title": "Bloqueo por evento", + "description": "No hay paso del camión por evento municipal.", + "category": "blocked" + } +] diff --git a/assets/json/colonias-rutas.json b/assets/json/colonias-rutas.json new file mode 100644 index 0000000..8d98999 --- /dev/null +++ b/assets/json/colonias-rutas.json @@ -0,0 +1,9 @@ +[ + { "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)" } +] diff --git a/assets/json/guia-rutas.json b/assets/json/guia-rutas.json new file mode 100644 index 0000000..6a6707f --- /dev/null +++ b/assets/json/guia-rutas.json @@ -0,0 +1,107 @@ +[ + { + "routeId": "RUTA-01", + "wasteType": "Orgánico", + "schedule": "06:30 - 07:15", + "days": ["Lunes", "Miércoles", "Viernes"], + "note": "Ruta base para zonas centro y arboledas." + }, + { + "routeId": "RUTA-02", + "wasteType": "Inorgánico", + "schedule": "07:15 - 08:00", + "days": ["Martes", "Jueves", "Sábado"], + "note": "Recolección de material no orgánico y reciclable." + }, + { + "routeId": "RUTA-03", + "wasteType": "Orgánico", + "schedule": "06:45 - 07:20", + "days": ["Lunes", "Miércoles", "Viernes"], + "note": "Cobertura para San Juanico y sectores cercanos." + }, + { + "routeId": "RUTA-04", + "wasteType": "Inorgánico", + "schedule": "07:00 - 07:40", + "days": ["Martes", "Jueves"], + "note": "Ruta de oriente con prioridad en residuos inorgánicos." + }, + { + "routeId": "RUTA-05", + "wasteType": "Orgánico", + "schedule": "14:15 - 15:00", + "days": ["Lunes", "Jueves"], + "note": "Turno vespertino para Rancho Seco y zonas similares." + }, + { + "routeId": "RUTA-06", + "wasteType": "Mixto", + "schedule": "07:00 - 08:00", + "days": ["Lunes", "Miércoles", "Viernes"], + "note": "Ruta extendida para zonas lejanas con recolección mixta." + }, + { + "routeId": "RUTA-07", + "wasteType": "Inorgánico", + "schedule": "08:00 - 08:45", + "days": ["Martes", "Jueves", "Sábado"], + "note": "Ciudad Industrial y corredores de comercio." + }, + { + "routeId": "RUTA-08", + "wasteType": "Orgánico", + "schedule": "06:40 - 07:25", + "days": ["Lunes", "Miércoles", "Viernes"], + "note": "Ruta universitaria y residencial con paso temprano." + }, + { + "routeId": "RUTA-09", + "wasteType": "Inorgánico", + "schedule": "07:30 - 08:10", + "days": ["Martes", "Jueves"], + "note": "Hospital y colonias cercanas con recolección inorgánica." + }, + { + "routeId": "RUTA-10", + "wasteType": "Orgánico", + "schedule": "06:50 - 07:35", + "days": ["Lunes", "Miércoles"], + "note": "Eje Juan Pablo II y zonas sur." + }, + { + "routeId": "RUTA-11", + "wasteType": "Mixto", + "schedule": "07:20 - 08:05", + "days": ["Martes", "Viernes"], + "note": "Zona de Oro con paso de ambos tipos por puntos especiales." + }, + { + "routeId": "RUTA-12", + "wasteType": "Inorgánico", + "schedule": "06:35 - 07:10", + "days": ["Martes", "Jueves"], + "note": "Las Insurgentes y calles aledañas." + }, + { + "routeId": "RUTA-13", + "wasteType": "Orgánico", + "schedule": "06:40 - 07:10", + "days": ["Lunes", "Miércoles", "Viernes"], + "note": "Trojes e Irrigación con salida temprana." + }, + { + "routeId": "RUTA-14", + "wasteType": "Inorgánico", + "schedule": "07:00 - 07:50", + "days": ["Martes", "Jueves"], + "note": "La Toscana con enfoque en inorgánico y reciclaje." + }, + { + "routeId": "RUTA-15", + "wasteType": "Orgánico", + "schedule": "07:15 - 07:55", + "days": ["Lunes", "Viernes"], + "note": "Camino a San José de Celaya, turno matutino." + } +] diff --git a/assets/json/notificaciones.json b/assets/json/notificaciones.json new file mode 100644 index 0000000..3f3114e --- /dev/null +++ b/assets/json/notificaciones.json @@ -0,0 +1,26 @@ +[ + { + "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." + } + }, + { + "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." + } + }, + { + "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." + } + } +] diff --git a/assets/json/perfiles.json b/assets/json/perfiles.json new file mode 100644 index 0000000..63297db --- /dev/null +++ b/assets/json/perfiles.json @@ -0,0 +1,6 @@ +[ + { "name": "Usuario Demo Centro", "email": "centro@demo.local", "password": "123456", "colonia": "Zona Centro", "routeId": "RUTA-01" }, + { "name": "Usuario Demo Arboledas", "email": "arboledas@demo.local", "password": "123456", "colonia": "Las Arboledas", "routeId": "RUTA-01" }, + { "name": "Usuario Demo Trojes", "email": "trojes@demo.local", "password": "123456", "colonia": "Trojes", "routeId": "RUTA-13" }, + { "name": "Usuario Demo Insurgentes", "email": "insurgentes@demo.local", "password": "123456", "colonia": "Las Insurgentes", "routeId": "RUTA-12" } +] diff --git a/assets/json/rutas.json b/assets/json/rutas.json new file mode 100644 index 0000000..06ea707 --- /dev/null +++ b/assets/json/rutas.json @@ -0,0 +1,242 @@ +[ + { + "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-02", + "name": "Sector Norte - Av. Tecnológico", + "truckId": 102, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z" }, + { "positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z" }, + { "positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z" }, + { "positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z" }, + { "positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z" }, + { "positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z" }, + { "positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50: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-06", + "name": "Norte Extremo - Rumbos de Roque", + "truckId": 106, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" }, + { "positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z" }, + { "positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z" }, + { "positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z" }, + { "positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" }, + { "positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z" }, + { "positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z" } + ] + }, + { + "routeId": "RUTA-07", + "name": "Nororiente - Ciudad Industrial", + "truckId": 107, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" }, + { "positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z" }, + { "positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z" }, + { "positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z" }, + { "positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z" }, + { "positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" }, + { "positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z" } + ] + }, + { + "routeId": "RUTA-08", + "name": "Suroriente - Universidad Latina", + "truckId": 108, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" }, + { "positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z" }, + { "positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z" }, + { "positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z" }, + { "positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z" }, + { "positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z" }, + { "positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z" } + ] + }, + { + "routeId": "RUTA-09", + "name": "Poniente - Hospital General", + "truckId": 109, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z" }, + { "positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" }, + { "positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z" }, + { "positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z" }, + { "positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z" }, + { "positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z" }, + { "positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z" } + ] + }, + { + "routeId": "RUTA-10", + "name": "Eje Juan Pablo II - Sede UG Sur", + "truckId": 110, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z" }, + { "positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z" }, + { "positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z" }, + { "positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z" }, + { "positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" }, + { "positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z" }, + { "positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z" } + ] + }, + { + "routeId": "RUTA-11", + "name": "Zona de Oro - Torres Landa", + "truckId": 111, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z" }, + { "positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z" }, + { "positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z" }, + { "positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z" }, + { "positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z" }, + { "positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z" }, + { "positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42: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" } + ] + }, + { + "routeId": "RUTA-14", + "name": "Sur Poniente - La Toscana", + "truckId": 114, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z" }, + { "positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z" }, + { "positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z" }, + { "positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z" }, + { "positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" }, + { "positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" }, + { "positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z" } + ] + }, + { + "routeId": "RUTA-15", + "name": "Norponiente - Camino a San José de Celaya", + "truckId": 115, + "status": "EN_RUTA", + "positions": [ + { "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z" }, + { "positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z" }, + { "positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z" }, + { "positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z" }, + { "positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z" }, + { "positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z" }, + { "positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z" }, + { "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z" } + ] + } +] diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/app.dart b/lib/app.dart index 0f7cbab..c88c7c5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'models/auth_session.dart'; import 'screens/auth_screen.dart'; -import 'screens/dashboard_screen.dart'; import 'services/address_repository.dart'; import 'services/auth_repository.dart'; @@ -12,8 +10,8 @@ class MyApp extends StatelessWidget { AuthRepository? authRepository, AddressRepository? addressRepository, this.enableLiveFeatures = true, - }) : _authRepository = authRepository ?? const HttpAuthRepository(), - _addressRepository = addressRepository ?? const HttpAddressRepository(); + }) : _authRepository = authRepository ?? const LocalAuthRepository(), + _addressRepository = addressRepository ?? const LocalAddressRepository(); final AuthRepository _authRepository; final AddressRepository _addressRepository; @@ -37,7 +35,7 @@ class MyApp extends StatelessWidget { } } -class AuthBootstrap extends StatefulWidget { +class AuthBootstrap extends StatelessWidget { const AuthBootstrap({ super.key, required this.authRepository, @@ -49,58 +47,12 @@ class AuthBootstrap extends StatefulWidget { final AddressRepository addressRepository; final bool enableLiveFeatures; - @override - State createState() => _AuthBootstrapState(); -} - -class _AuthBootstrapState extends State { - late final Future _sessionFuture; - - @override - void initState() { - super.initState(); - _sessionFuture = widget.authRepository.restoreSession(); - } - @override Widget build(BuildContext context) { - return FutureBuilder( - future: _sessionFuture, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const _LoadingView(); - } - - final session = snapshot.data; - if (session != null) { - return DashboardScreen( - authRepository: widget.authRepository, - addressRepository: widget.addressRepository, - session: session, - savedAddress: null, - enableLiveFeatures: widget.enableLiveFeatures, - ); - } - - return AuthScreen( - authRepository: widget.authRepository, - addressRepository: widget.addressRepository, - enableLiveFeatures: widget.enableLiveFeatures, - ); - }, - ); - } -} - -class _LoadingView extends StatelessWidget { - const _LoadingView(); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), + return AuthScreen( + authRepository: authRepository, + addressRepository: addressRepository, + enableLiveFeatures: enableLiveFeatures, ); } } diff --git a/lib/app_config.dart b/lib/app_config.dart index 04c03d1..598d6ab 100644 --- a/lib/app_config.dart +++ b/lib/app_config.dart @@ -1,6 +1,4 @@ class AppConfig { - static const String apiBaseUrl = String.fromEnvironment( - 'API_BASE_URL', - defaultValue: 'http://10.77.234.29:3000', - ); + static const String appName = 'Acceso Local'; + static const bool useLocalDataOnly = true; } diff --git a/lib/models/calendar_event_entry.dart b/lib/models/calendar_event_entry.dart new file mode 100644 index 0000000..a5f314a --- /dev/null +++ b/lib/models/calendar_event_entry.dart @@ -0,0 +1,22 @@ +class CalendarEventEntry { + const CalendarEventEntry({ + required this.date, + required this.title, + required this.description, + required this.category, + }); + + final DateTime date; + final String title; + final String description; + final String category; + + factory CalendarEventEntry.fromJson(Map json) { + return CalendarEventEntry( + date: DateTime.parse(json['date'].toString()), + title: json['title'].toString(), + description: json['description'].toString(), + category: json['category'].toString(), + ); + } +} diff --git a/lib/models/colony_route.dart b/lib/models/colony_route.dart new file mode 100644 index 0000000..43a3d3f --- /dev/null +++ b/lib/models/colony_route.dart @@ -0,0 +1,19 @@ +class ColonyRoute { + const ColonyRoute({ + required this.colonia, + required this.routeId, + required this.horarioEstimado, + }); + + final String colonia; + final String routeId; + final String horarioEstimado; + + factory ColonyRoute.fromJson(Map json) { + return ColonyRoute( + colonia: json['colonia'].toString(), + routeId: json['routeId'].toString(), + horarioEstimado: json['horarioEstimado'].toString(), + ); + } +} diff --git a/lib/models/demo_profile.dart b/lib/models/demo_profile.dart new file mode 100644 index 0000000..e84e5d6 --- /dev/null +++ b/lib/models/demo_profile.dart @@ -0,0 +1,25 @@ +class DemoProfile { + const DemoProfile({ + required this.name, + required this.email, + required this.password, + required this.colonia, + required this.routeId, + }); + + final String name; + final String email; + final String password; + final String colonia; + final String routeId; + + factory DemoProfile.fromJson(Map json) { + return DemoProfile( + name: json['name'].toString(), + email: json['email'].toString(), + password: json['password'].toString(), + colonia: json['colonia'].toString(), + routeId: json['routeId'].toString(), + ); + } +} diff --git a/lib/models/route_guide_entry.dart b/lib/models/route_guide_entry.dart new file mode 100644 index 0000000..477620e --- /dev/null +++ b/lib/models/route_guide_entry.dart @@ -0,0 +1,30 @@ +class RouteGuideEntry { + const RouteGuideEntry({ + required this.routeId, + required this.wasteType, + required this.schedule, + required this.days, + required this.note, + }); + + final String routeId; + final String wasteType; + final String schedule; + final List days; + final String note; + + factory RouteGuideEntry.fromJson(Map json) { + final daysJson = json['days']; + final days = daysJson is List + ? daysJson.map((day) => day.toString()).toList(growable: false) + : []; + + return RouteGuideEntry( + routeId: json['routeId'].toString(), + wasteType: json['wasteType'].toString(), + schedule: json['schedule'].toString(), + days: days, + note: json['note'].toString(), + ); + } +} diff --git a/lib/models/route_notification.dart b/lib/models/route_notification.dart new file mode 100644 index 0000000..a8ff73d --- /dev/null +++ b/lib/models/route_notification.dart @@ -0,0 +1,26 @@ +class RouteNotification { + const RouteNotification({ + required this.triggerEvent, + required this.condition, + required this.title, + required this.body, + }); + + final String triggerEvent; + final String condition; + final String title; + final String body; + + factory RouteNotification.fromJson(Map json) { + final payload = json['pushPayload'] is Map + ? json['pushPayload'] as Map + : {}; + + return RouteNotification( + triggerEvent: json['triggerEvent'].toString(), + condition: json['condition'].toString(), + title: payload['title']?.toString() ?? '', + body: payload['body']?.toString() ?? '', + ); + } +} diff --git a/lib/models/route_position.dart b/lib/models/route_position.dart new file mode 100644 index 0000000..5eda9e1 --- /dev/null +++ b/lib/models/route_position.dart @@ -0,0 +1,25 @@ +class RoutePosition { + const RoutePosition({ + required this.positionId, + required this.lat, + required this.lng, + required this.speed, + required this.timestamp, + }); + + final int positionId; + final double lat; + final double lng; + final double speed; + final DateTime timestamp; + + factory RoutePosition.fromJson(Map json) { + return RoutePosition( + positionId: (json['positionId'] as num).toInt(), + lat: (json['lat'] as num).toDouble(), + lng: (json['lng'] as num).toDouble(), + speed: (json['speed'] as num).toDouble(), + timestamp: DateTime.parse(json['timestamp'].toString()), + ); + } +} diff --git a/lib/models/truck_route.dart b/lib/models/truck_route.dart new file mode 100644 index 0000000..9840ec7 --- /dev/null +++ b/lib/models/truck_route.dart @@ -0,0 +1,35 @@ +import 'route_position.dart'; + +class TruckRoute { + const TruckRoute({ + required this.routeId, + required this.name, + required this.truckId, + required this.status, + required this.positions, + }); + + final String routeId; + final String name; + final int truckId; + final String status; + final List positions; + + factory TruckRoute.fromJson(Map json) { + final positionsJson = json['positions']; + final positions = positionsJson is List + ? positionsJson + .whereType>() + .map(RoutePosition.fromJson) + .toList(growable: false) + : []; + + return TruckRoute( + routeId: json['routeId'].toString(), + name: json['name'].toString(), + truckId: (json['truckId'] as num).toInt(), + status: json['status'].toString(), + positions: positions, + ); + } +} diff --git a/lib/screens/address_screen.dart b/lib/screens/address_screen.dart index deedaa2..8caf4cd 100644 --- a/lib/screens/address_screen.dart +++ b/lib/screens/address_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -import '../app_config.dart'; import '../models/address_entry.dart'; import '../models/auth_session.dart'; import '../services/auth_repository.dart'; import '../services/address_repository.dart'; import 'dashboard_screen.dart'; +final RegExp _lettersOnly = RegExp(r"[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\s]"); +final RegExp _digitsOnly = RegExp(r'[0-9]'); + class AddressScreen extends StatefulWidget { const AddressScreen({ super.key, @@ -44,6 +47,9 @@ class _AddressScreenState extends State { Future _saveAddress() async { if (!(_formKey.currentState?.validate() ?? false)) { + setState(() { + _errorMessage = 'Respete los campos'; + }); return; } @@ -94,7 +100,7 @@ class _AddressScreenState extends State { return; } setState(() { - _errorMessage = 'No se pudo guardar la dirección. Revisa el backend.'; + _errorMessage = 'No se pudo guardar la dirección. Revisa los datos locales.'; }); } finally { if (mounted) { @@ -137,7 +143,7 @@ class _AddressScreenState extends State { ), const SizedBox(height: 8), Text( - 'Ingresa la dirección de tu casa y se enviará al backend para guardarla en PostgreSQL.', + 'Ingresa la dirección de tu casa. La app la guardará de forma local para usarla en el tablero.', style: TextStyle(color: Colors.grey.shade700, height: 1.4), ), const SizedBox(height: 20), @@ -151,7 +157,8 @@ class _AddressScreenState extends State { children: [ TextFormField( controller: _houseNumberController, - keyboardType: TextInputType.text, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(_digitsOnly)], decoration: const InputDecoration( labelText: 'Número de casa', prefixIcon: Icon(Icons.home_outlined), @@ -159,7 +166,7 @@ class _AddressScreenState extends State { ), validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Ingresa el número de casa'; + return 'Respete los campos'; } return null; }, @@ -167,6 +174,9 @@ class _AddressScreenState extends State { const SizedBox(height: 16), TextFormField( controller: _coloniaController, + keyboardType: TextInputType.name, + textCapitalization: TextCapitalization.words, + inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)], decoration: const InputDecoration( labelText: 'Colonia', prefixIcon: Icon(Icons.location_city_outlined), @@ -174,7 +184,7 @@ class _AddressScreenState extends State { ), validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Ingresa la colonia'; + return 'Respete los campos'; } return null; }, @@ -182,6 +192,9 @@ class _AddressScreenState extends State { const SizedBox(height: 16), TextFormField( controller: _streetController, + keyboardType: TextInputType.name, + textCapitalization: TextCapitalization.words, + inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)], decoration: const InputDecoration( labelText: 'Calle', prefixIcon: Icon(Icons.signpost_outlined), @@ -189,7 +202,7 @@ class _AddressScreenState extends State { ), validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Ingresa la calle'; + return 'Respete los campos'; } return null; }, @@ -210,11 +223,6 @@ class _AddressScreenState extends State { ), ), const SizedBox(height: 12), - Text( - 'Base URL configurada: ${AppConfig.apiBaseUrl}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - textAlign: TextAlign.center, - ), ], ), ), diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 487045d..7816b7b 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -import '../app_config.dart'; +import '../models/demo_profile.dart'; import '../models/auth_session.dart'; import '../services/address_repository.dart'; import '../services/auth_repository.dart'; +import '../services/local_seed_repository.dart'; import 'address_screen.dart'; +final RegExp _lettersOnly = RegExp(r"[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\s]"); +final RegExp _addressText = RegExp(r"[a-zA-Z0-9áéíóúÁÉÍÓÚñÑüÜ#\-\s]"); +final RegExp _emailChars = RegExp(r"[a-zA-Z0-9@._+\-]"); + class AuthScreen extends StatefulWidget { const AuthScreen({ super.key, @@ -34,6 +40,14 @@ class _AuthScreenState extends State { bool _isLoading = false; String? _errorMessage; + LocalSeedData? _seedData; + bool _loadingSeedData = true; + + @override + void initState() { + super.initState(); + _loadSeedData(); + } @override void dispose() { @@ -46,8 +60,42 @@ class _AuthScreenState extends State { super.dispose(); } + Future _loadSeedData() async { + final seedData = await LocalSeedRepository.instance.load(); + if (!mounted) { + return; + } + + setState(() { + _seedData = seedData; + _loadingSeedData = false; + }); + } + + void _fillDemoProfile(DemoProfile profile) { + _loginEmailController.text = profile.email; + _loginPasswordController.text = profile.password; + _registerNameController.text = profile.name; + _registerEmailController.text = profile.email; + _registerPasswordController.text = profile.password; + _registerConfirmPasswordController.text = profile.password; + } + + Future _useDemoProfile(DemoProfile profile) async { + _fillDemoProfile(profile); + await _submit(() { + return widget.authRepository.signIn( + email: profile.email, + password: profile.password, + ); + }); + } + Future _signIn() async { if (!(_loginFormKey.currentState?.validate() ?? false)) { + setState(() { + _errorMessage = 'Respete los campos'; + }); return; } @@ -61,6 +109,9 @@ class _AuthScreenState extends State { Future _signUp() async { if (!(_registerFormKey.currentState?.validate() ?? false)) { + setState(() { + _errorMessage = 'Respete los campos'; + }); return; } @@ -106,7 +157,7 @@ class _AuthScreenState extends State { return; } setState(() { - _errorMessage = 'No se pudo completar la operación. Verifica el backend y vuelve a intentar.'; + _errorMessage = 'No se pudo completar la operación. Revisa los datos locales y vuelve a intentar.'; }); } finally { if (mounted) { @@ -167,6 +218,13 @@ class _AuthScreenState extends State { style: TextStyle(color: Colors.grey.shade700, height: 1.4), ), const SizedBox(height: 20), + if (!_loadingSeedData && _seedData != null && _seedData!.demoProfiles.isNotEmpty) ...[ + _DemoProfilesSection( + profiles: _seedData!.demoProfiles, + onProfileSelected: _useDemoProfile, + ), + const SizedBox(height: 16), + ], Container( decoration: BoxDecoration( color: const Color(0xFFF1F5F9), @@ -221,11 +279,6 @@ class _AuthScreenState extends State { ), ), const SizedBox(height: 12), - Text( - 'Base URL configurada: ${AppConfig.apiBaseUrl}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - textAlign: TextAlign.center, - ), ], ), ), @@ -264,6 +317,7 @@ class _LoginForm extends StatelessWidget { TextFormField( controller: emailController, keyboardType: TextInputType.emailAddress, + inputFormatters: [FilteringTextInputFormatter.allow(_emailChars)], decoration: const InputDecoration( labelText: 'Correo electrónico', prefixIcon: Icon(Icons.email_outlined), @@ -273,7 +327,7 @@ class _LoginForm extends StatelessWidget { if (value == null || value.trim().isEmpty) { return 'Ingresa tu correo'; } - if (!value.contains('@')) { + if (!value.contains('@') || value.startsWith('@') || value.endsWith('@')) { return 'Ingresa un correo válido'; } return null; @@ -347,6 +401,8 @@ class _RegisterForm extends StatelessWidget { TextFormField( controller: nameController, textCapitalization: TextCapitalization.words, + keyboardType: TextInputType.name, + inputFormatters: [FilteringTextInputFormatter.allow(_lettersOnly)], decoration: const InputDecoration( labelText: 'Nombre', prefixIcon: Icon(Icons.person_outline), @@ -363,6 +419,7 @@ class _RegisterForm extends StatelessWidget { TextFormField( controller: emailController, keyboardType: TextInputType.emailAddress, + inputFormatters: [FilteringTextInputFormatter.allow(_emailChars)], decoration: const InputDecoration( labelText: 'Correo electrónico', prefixIcon: Icon(Icons.email_outlined), @@ -372,7 +429,7 @@ class _RegisterForm extends StatelessWidget { if (value == null || value.trim().isEmpty) { return 'Ingresa tu correo'; } - if (!value.contains('@')) { + if (!value.contains('@') || value.startsWith('@') || value.endsWith('@')) { return 'Ingresa un correo válido'; } return null; @@ -382,6 +439,7 @@ class _RegisterForm extends StatelessWidget { TextFormField( controller: passwordController, obscureText: true, + inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))], decoration: const InputDecoration( labelText: 'Contraseña', prefixIcon: Icon(Icons.lock_outline), @@ -401,6 +459,7 @@ class _RegisterForm extends StatelessWidget { TextFormField( controller: confirmPasswordController, obscureText: true, + inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))], decoration: const InputDecoration( labelText: 'Confirmar contraseña', prefixIcon: Icon(Icons.lock_reset_outlined), @@ -458,4 +517,47 @@ class _AuthStatusBanner extends StatelessWidget { ), ); } +} + +class _DemoProfilesSection extends StatelessWidget { + const _DemoProfilesSection({ + required this.profiles, + required this.onProfileSelected, + }); + + final List profiles; + final Future Function(DemoProfile profile) onProfileSelected; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + color: const Color(0xFFF8FAFC), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Perfiles demo', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Text('Toca un perfil para llenar el formulario de acceso.', style: TextStyle(color: Colors.grey.shade700)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: profiles + .map( + (profile) => ActionChip( + label: Text('${profile.name} • ${profile.routeId}'), + onPressed: () => onProfileSelected(profile), + ), + ) + .toList(growable: false), + ), + ], + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 6d74dff..9ff7192 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -8,12 +8,127 @@ import 'package:latlong2/latlong.dart'; import 'package:table_calendar/table_calendar.dart'; import '../models/address_entry.dart'; +import '../models/colony_route.dart'; +import '../models/calendar_event_entry.dart'; import '../models/address_record.dart'; import '../models/auth_session.dart'; +import '../models/route_guide_entry.dart'; +import '../models/route_notification.dart'; +import '../models/truck_route.dart'; import '../services/address_repository.dart'; import '../services/auth_repository.dart'; +import '../services/local_seed_repository.dart'; import 'auth_screen.dart'; +String formatScheduleAsAmPm(String schedule) { + final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); + if (segments.length != 2) { + return schedule; + } + + TimeOfDay? parseSegment(String value) { + final parts = value.split(':'); + if (parts.length != 2) { + return null; + } + final hour = int.tryParse(parts[0].trim()); + final minute = int.tryParse(parts[1].trim()); + if (hour == null || minute == null) { + return null; + } + return TimeOfDay(hour: hour, minute: minute); + } + + String formatTime(TimeOfDay timeOfDay) { + final hour = timeOfDay.hourOfPeriod == 0 ? 12 : timeOfDay.hourOfPeriod; + final minute = timeOfDay.minute.toString().padLeft(2, '0'); + final suffix = timeOfDay.period == DayPeriod.am ? 'AM' : 'PM'; + return '$hour:$minute $suffix'; + } + + final start = parseSegment(segments[0]); + final end = parseSegment(segments[1]); + if (start == null || end == null) { + return schedule; + } + + return '${formatTime(start)} - ${formatTime(end)}'; +} + +String guideCountdownText(RouteGuideEntry guide, DateTime now) { + final weekdays = guide.days.map((day) { + switch (day.trim().toLowerCase()) { + case 'lunes': + return DateTime.monday; + case 'martes': + return DateTime.tuesday; + case 'miercoles': + case 'miércoles': + return DateTime.wednesday; + case 'jueves': + return DateTime.thursday; + case 'viernes': + return DateTime.friday; + case 'sabado': + case 'sábado': + return DateTime.saturday; + case 'domingo': + return DateTime.sunday; + default: + return null; + } + }).whereType().toList(growable: false); + + if (weekdays.isEmpty) { + return 'Sin horario definido'; + } + + DateTime? parseStart(DateTime date) { + final parts = guide.schedule.split('-').first.trim().split(':'); + if (parts.length != 2) { + return null; + } + final hour = int.tryParse(parts[0].trim()); + final minute = int.tryParse(parts[1].trim()); + if (hour == null || minute == null) { + return null; + } + return DateTime(date.year, date.month, date.day, hour, minute); + } + + for (var offset = 0; offset < 8; offset++) { + final candidateDate = now.add(Duration(days: offset)); + if (!weekdays.contains(candidateDate.weekday)) { + continue; + } + final start = parseStart(candidateDate); + if (start == null) { + return 'Sin horario definido'; + } + if (start.isAfter(now)) { + return 'Faltan ${formatDuration(start.difference(now))}'; + } + if (offset == 0) { + return 'Llega ahora'; + } + } + + return 'Próxima llegada en breve'; +} + +String formatDuration(Duration duration) { + final totalMinutes = duration.inMinutes; + if (totalMinutes <= 0) { + return '0 min'; + } + final hours = totalMinutes ~/ 60; + final minutes = totalMinutes % 60; + if (hours > 0) { + return minutes > 0 ? '$hours h $minutes min' : '$hours h'; + } + return '$minutes min'; +} + class DashboardScreen extends StatefulWidget { const DashboardScreen({ super.key, @@ -53,10 +168,18 @@ class _DashboardScreenState extends State { Timer? _truckTimer; LatLng? _truckPosition; bool _truckVisible = false; + LocalSeedData? _seedData; + TruckRoute? _activeRoute; + ColonyRoute? _selectedColonyRoute; + int _routePlaybackIndex = 0; bool _loadingAddresses = true; String? _addressesError; List _addresses = []; + String? _selectedGuideRouteId; + bool _mapCenterResolved = false; + DateTime _now = DateTime.now(); + Timer? _clockTimer; DateTime _focusedDay = DateTime.now(); DateTime? _selectedDay; @@ -69,18 +192,26 @@ class _DashboardScreenState extends State { if (!_showLiveFeatures) { _isLoadingLocation = false; _loadingAddresses = false; - _seedTruckSimulation(); - return; } + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) { + return; + } + setState(() { + _now = DateTime.now(); + }); + }); + + unawaited(_loadLocalSeedData()); unawaited(_loadLocation()); unawaited(_loadAddresses()); - _startTruckSimulation(); } @override void dispose() { _truckTimer?.cancel(); + _clockTimer?.cancel(); _calendarNoteController.dispose(); _newHouseNumberController.dispose(); _newColoniaController.dispose(); @@ -90,6 +221,203 @@ class _DashboardScreenState extends State { DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day); + String _formatTimeAsAmPm(TimeOfDay timeOfDay) { + final hour = timeOfDay.hourOfPeriod == 0 ? 12 : timeOfDay.hourOfPeriod; + final minute = timeOfDay.minute.toString().padLeft(2, '0'); + final suffix = timeOfDay.period == DayPeriod.am ? 'AM' : 'PM'; + return '$hour:$minute $suffix'; + } + + String _formatScheduleAmPm(String schedule) { + final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); + if (segments.length != 2) { + return schedule; + } + + TimeOfDay? parseSegment(String value) { + final parts = value.split(':'); + if (parts.length != 2) { + return null; + } + final hour = int.tryParse(parts[0].trim()); + final minute = int.tryParse(parts[1].trim()); + if (hour == null || minute == null) { + return null; + } + return TimeOfDay(hour: hour, minute: minute); + } + + final start = parseSegment(segments[0]); + final end = parseSegment(segments[1]); + if (start == null || end == null) { + return schedule; + } + + return '${_formatTimeAsAmPm(start)} - ${_formatTimeAsAmPm(end)}'; + } + + int? _weekdayFromSpanish(String day) { + switch (day.trim().toLowerCase()) { + case 'lunes': + return DateTime.monday; + case 'martes': + return DateTime.tuesday; + case 'miercoles': + case 'miércoles': + return DateTime.wednesday; + case 'jueves': + return DateTime.thursday; + case 'viernes': + return DateTime.friday; + case 'sabado': + case 'sábado': + return DateTime.saturday; + case 'domingo': + return DateTime.sunday; + default: + return null; + } + } + + DateTime? _parseScheduleStart(String schedule, DateTime baseDate) { + final segments = schedule.split('-').map((segment) => segment.trim()).toList(growable: false); + if (segments.isEmpty) { + return null; + } + + final parts = segments.first.split(':'); + if (parts.length != 2) { + return null; + } + + final hour = int.tryParse(parts[0].trim()); + final minute = int.tryParse(parts[1].trim()); + if (hour == null || minute == null) { + return null; + } + + return DateTime(baseDate.year, baseDate.month, baseDate.day, hour, minute); + } + + DateTime? _nextGuideArrival(RouteGuideEntry guide, DateTime reference) { + final availableWeekdays = guide.days + .map(_weekdayFromSpanish) + .whereType() + .toList(growable: false); + + if (availableWeekdays.isEmpty) { + return null; + } + + for (var offset = 0; offset < 8; offset++) { + final candidateDate = reference.add(Duration(days: offset)); + if (!availableWeekdays.contains(candidateDate.weekday)) { + continue; + } + + final candidateStart = _parseScheduleStart(guide.schedule, candidateDate); + if (candidateStart == null) { + return null; + } + + if (candidateStart.isAfter(reference)) { + return candidateStart; + } + + if (offset == 0 && candidateStart.difference(reference).inMinutes.abs() <= 30) { + return candidateStart; + } + } + + final nextDay = reference.add(const Duration(days: 7)); + final nextMatch = guide.days.map(_weekdayFromSpanish).whereType().first; + var searchDate = nextDay; + while (searchDate.weekday != nextMatch) { + searchDate = searchDate.add(const Duration(days: 1)); + } + return _parseScheduleStart(guide.schedule, searchDate); + } + + String _formatDuration(Duration duration) { + final totalMinutes = duration.inMinutes; + if (totalMinutes <= 0) { + return 'ahora'; + } + final hours = totalMinutes ~/ 60; + final minutes = totalMinutes % 60; + if (hours > 0) { + return minutes > 0 ? '$hours h $minutes min' : '$hours h'; + } + return '$minutes min'; + } + + LatLng _fallbackMapCenter(LocalSeedData seedData) { + final route = seedData.routeForColonia(widget.savedAddress?.colonia) ?? seedData.defaultRoute; + if (route == null || route.positions.isEmpty) { + return const LatLng(20.5111, -100.9037); + } + + final points = route.positions; + final averageLat = points.map((point) => point.lat).reduce((value, element) => value + element) / points.length; + final averageLng = points.map((point) => point.lng).reduce((value, element) => value + element) / points.length; + return LatLng(averageLat, averageLng); + } + + List _projectRoutePoints(TruckRoute? route, LatLng center) { + final positions = route?.positions; + if (positions == null || positions.isEmpty) { + return []; + } + + final baseLat = positions.first.lat; + final baseLng = positions.first.lng; + return positions + .map( + (position) => LatLng( + center.latitude + (position.lat - baseLat), + center.longitude + (position.lng - baseLng), + ), + ) + .toList(growable: false); + } + + void _moveMapTo(LatLng targetCenter) { + _center = targetCenter; + if (_mapCenterResolved) { + _mapController.move(targetCenter, 15); + return; + } + _mapCenterResolved = true; + } + + Future _loadLocalSeedData() async { + final seedData = await LocalSeedRepository.instance.load(); + if (!mounted) { + return; + } + + final selectedColonyRoute = seedData.colonyRouteForColonia(widget.savedAddress?.colonia ?? widget.session.displayName); + final activeRoute = seedData.routeForColonia(widget.savedAddress?.colonia) ?? seedData.defaultRoute; + + setState(() { + _seedData = seedData; + _selectedColonyRoute = selectedColonyRoute; + _activeRoute = activeRoute; + _selectedGuideRouteId = activeRoute?.routeId ?? seedData.defaultRoute?.routeId; + if (!_showLiveFeatures) { + final fallbackCenter = _fallbackMapCenter(seedData); + _moveMapTo(fallbackCenter); + if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { + final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); + _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; + _truckVisible = true; + } + } + }); + + _startTruckSimulation(); + } + void _addNotification(String message) { if (!mounted) { return; @@ -111,6 +439,33 @@ class _DashboardScreenState extends State { ); } + void _startRandomTruckSimulation() { + _truckTimer?.cancel(); + _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) { + final distanceMeters = _random.nextDouble() * 40.0; + final newTruckPosition = _truckOffsetFromCenter(distanceMeters); + final wasVisible = _truckVisible; + final isVisible = distanceMeters <= 20.0; + + if (!mounted) { + return; + } + + setState(() { + _truckPosition = newTruckPosition; + _truckVisible = isVisible; + }); + + if (isVisible != wasVisible) { + _addNotification( + isVisible + ? 'El camión de basura apareció a ${distanceMeters.toStringAsFixed(1)} m.' + : 'El camión de basura salió del rango visible.', + ); + } + }); + } + Future _loadLocation() async { try { final permission = await Geolocator.checkPermission(); @@ -127,6 +482,21 @@ class _DashboardScreenState extends State { _locationError = 'Permiso de ubicación no concedido. Mostrando mapa por defecto.'; _isLoadingLocation = false; }); + + if (_seedData != null) { + final fallbackCenter = _fallbackMapCenter(_seedData!); + if (mounted) { + setState(() { + _center = fallbackCenter; + if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { + final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); + _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; + _truckVisible = true; + } + }); + _mapController.move(fallbackCenter, 15); + } + } return; } @@ -138,7 +508,7 @@ class _DashboardScreenState extends State { } setState(() { - _center = newCenter; + _moveMapTo(newCenter); _isLoadingLocation = false; _locationError = null; }); @@ -152,6 +522,19 @@ class _DashboardScreenState extends State { _locationError = 'No se pudo obtener la ubicación actual. Se usó una referencia por defecto.'; _isLoadingLocation = false; }); + + if (_seedData != null) { + final fallbackCenter = _fallbackMapCenter(_seedData!); + setState(() { + _center = fallbackCenter; + if (_activeRoute != null && _activeRoute!.positions.isNotEmpty) { + final projectedRoute = _projectRoutePoints(_activeRoute, fallbackCenter); + _truckPosition = projectedRoute.isNotEmpty ? projectedRoute.first : fallbackCenter; + _truckVisible = true; + } + }); + _mapController.move(fallbackCenter, 15); + } } } @@ -178,32 +561,87 @@ class _DashboardScreenState extends State { } void _startTruckSimulation() { - _truckTimer?.cancel(); - _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) { - final distanceMeters = _random.nextDouble() * 40.0; - final newTruckPosition = _truckOffsetFromCenter(distanceMeters); - final wasVisible = _truckVisible; - final isVisible = distanceMeters <= 20.0; + final route = _activeRoute; + final positions = route?.positions; + if (positions == null || positions.isEmpty) { + _startRandomTruckSimulation(); + return; + } + _truckTimer?.cancel(); + _routePlaybackIndex = 0; + _truckTimer = Timer.periodic(const Duration(seconds: 4), (_) { if (!mounted) { return; } + final projectedRoute = _projectRoutePoints(route, _center); + final currentPosition = positions[_routePlaybackIndex % positions.length]; + final currentLatLng = projectedRoute.isEmpty + ? LatLng(currentPosition.lat, currentPosition.lng) + : projectedRoute[_routePlaybackIndex % projectedRoute.length]; + final distanceMeters = Geolocator.distanceBetween( + _center.latitude, + _center.longitude, + currentLatLng.latitude, + currentLatLng.longitude, + ); + final wasVisible = _truckVisible; + final isVisible = distanceMeters <= 20.0; + setState(() { - _truckPosition = newTruckPosition; + _truckPosition = currentLatLng; _truckVisible = isVisible; }); + _pushRouteNotification(currentPosition.positionId); + if (isVisible != wasVisible) { _addNotification( isVisible - ? 'El camión de basura apareció a ${distanceMeters.toStringAsFixed(1)} m.' - : 'El camión de basura salió del rango visible.', + ? 'El camión apareció sobre la ruta ${route?.routeId ?? 'RUTA'}.' + : 'El camión salió del rango visible.', ); } + + _routePlaybackIndex++; }); } + void _pushRouteNotification(int positionId) { + final triggerEvent = switch (positionId) { + 2 => 'ROUTE_START', + 4 => 'TRUCK_PROXIMITY', + 8 => 'ROUTE_COMPLETED', + _ => '', + }; + + final notification = _notificationByTrigger(triggerEvent); + + if (notification != null) { + _addNotification('${notification.title}: ${notification.body}'); + } + } + + RouteNotification? _notificationByTrigger(String triggerEvent) { + if (triggerEvent.isEmpty) { + return null; + } + + final seedData = _seedData; + if (seedData == null) { + return null; + } + + for (final notification in seedData.notifications) { + if (notification.triggerEvent == triggerEvent) { + return notification; + } + } + + return null; + } + LatLng _truckOffsetFromCenter(double distanceMeters) { final angle = _random.nextDouble() * 2 * pi; final metersPerDegreeLat = 111320.0; @@ -243,6 +681,32 @@ class _DashboardScreenState extends State { _addNotification('Se guardó texto en el calendario para ${_selectedDay!.day}/${_selectedDay!.month}.'); } + void _selectCalendarDay(DateTime day, DateTime focusedDay) { + final normalizedDay = _normalizeDay(day); + final savedNote = _calendarNotes[normalizedDay]; + + setState(() { + _selectedDay = day; + _focusedDay = focusedDay; + _calendarNoteController.text = savedNote ?? ''; + }); + } + + CalendarEventEntry? _seedEventForDay(DateTime day) { + final seedData = _seedData; + if (seedData == null) { + return null; + } + + final normalizedDay = _normalizeDay(day); + for (final event in seedData.calendarEvents) { + if (event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) { + return event; + } + } + return null; + } + Future _saveNewAddress() async { if (_newHouseNumberController.text.trim().isEmpty || _newColoniaController.text.trim().isEmpty || @@ -282,8 +746,34 @@ class _DashboardScreenState extends State { } } + void _selectGuideRoute(String routeId) { + setState(() { + _selectedGuideRouteId = routeId; + }); + } + @override Widget build(BuildContext context) { + final routePoints = _projectRoutePoints(_activeRoute, _center); + final selectedDay = _selectedDay == null ? null : _normalizeDay(_selectedDay!); + final selectedSavedNote = selectedDay == null ? null : _calendarNotes[selectedDay]; + final selectedSeedEvents = selectedDay == null ? [] : (_seedData?.eventsForDay(selectedDay) ?? []); + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final bottomNavigation = NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + }); + }, + destinations: const [ + NavigationDestination(icon: Icon(Icons.my_location_outlined), selectedIcon: Icon(Icons.my_location), label: 'Mapa'), + NavigationDestination(icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: 'Calendario'), + NavigationDestination(icon: Icon(Icons.notifications_outlined), selectedIcon: Icon(Icons.notifications), label: 'Avisos'), + NavigationDestination(icon: Icon(Icons.input_outlined), selectedIcon: Icon(Icons.input), label: 'Datos'), + ], + ); + final pages = [ _MapSection( center: _center, @@ -294,19 +784,20 @@ class _DashboardScreenState extends State { mapController: _mapController, showTiles: _showLiveFeatures, address: widget.savedAddress, + activeRoute: _activeRoute, + selectedColonyRoute: _selectedColonyRoute, + routePoints: routePoints, ), _CalendarSection( focusedDay: _focusedDay, selectedDay: _selectedDay, calendarFormat: _calendarFormat, notes: _calendarNotes, + seedEvents: _seedData?.calendarEvents ?? const [], + selectedSavedNote: selectedSavedNote, + selectedSeedEvents: selectedSeedEvents, noteController: _calendarNoteController, - onSelectedDay: (day, focusedDay) { - setState(() { - _selectedDay = day; - _focusedDay = focusedDay; - }); - }, + onSelectedDay: _selectCalendarDay, onFormatChanged: (format) { setState(() { _calendarFormat = format; @@ -323,6 +814,12 @@ class _DashboardScreenState extends State { loadingAddresses: _loadingAddresses, addressesError: _addressesError, addresses: _addresses, + seedData: _seedData, + activeRoute: _activeRoute, + selectedGuideRouteId: _selectedGuideRouteId, + selectedColonyRoute: _selectedColonyRoute, + now: _now, + onSelectGuideRoute: _selectGuideRoute, houseNumberController: _newHouseNumberController, coloniaController: _newColoniaController, streetController: _newStreetController, @@ -332,35 +829,86 @@ class _DashboardScreenState extends State { return Scaffold( body: SafeArea( - child: Column( - children: [ - _UserHeader( - session: widget.session, - onLogout: () => _logOut(context), - ), - Expanded( - child: IndexedStack( - index: _selectedIndex, - children: pages, + child: isLandscape + ? Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) { + setState(() { + _selectedIndex = index; + }); + }, + labelType: NavigationRailLabelType.all, + leading: Padding( + padding: const EdgeInsets.only(top: 8), + child: CircleAvatar( + radius: 22, + backgroundColor: Colors.teal.shade700, + child: Text( + widget.session.displayName.isNotEmpty ? widget.session.displayName[0].toUpperCase() : 'U', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800), + ), + ), + ), + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.my_location_outlined), + selectedIcon: Icon(Icons.my_location), + label: Text('Mapa'), + ), + NavigationRailDestination( + icon: Icon(Icons.calendar_month_outlined), + selectedIcon: Icon(Icons.calendar_month), + label: Text('Calendario'), + ), + NavigationRailDestination( + icon: Icon(Icons.notifications_outlined), + selectedIcon: Icon(Icons.notifications), + label: Text('Avisos'), + ), + NavigationRailDestination( + icon: Icon(Icons.input_outlined), + selectedIcon: Icon(Icons.input), + label: Text('Datos'), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded( + child: Column( + children: [ + _UserHeader( + session: widget.session, + onLogout: () => _logOut(context), + ), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: pages, + ), + ), + ], + ), + ), + ], + ) + : Column( + children: [ + _UserHeader( + session: widget.session, + onLogout: () => _logOut(context), + ), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: pages, + ), + ), + ], ), - ), - ], - ), - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _selectedIndex, - onDestinationSelected: (index) { - setState(() { - _selectedIndex = index; - }); - }, - destinations: const [ - NavigationDestination(icon: Icon(Icons.my_location_outlined), selectedIcon: Icon(Icons.my_location), label: 'Mapa'), - NavigationDestination(icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: 'Calendario'), - NavigationDestination(icon: Icon(Icons.notifications_outlined), selectedIcon: Icon(Icons.notifications), label: 'Avisos'), - NavigationDestination(icon: Icon(Icons.input_outlined), selectedIcon: Icon(Icons.input), label: 'Datos'), - ], ), + bottomNavigationBar: isLandscape ? Offstage(offstage: true, child: bottomNavigation) : bottomNavigation, ); } } @@ -423,6 +971,9 @@ class _MapSection extends StatelessWidget { required this.mapController, required this.showTiles, required this.address, + required this.activeRoute, + required this.selectedColonyRoute, + required this.routePoints, }); final LatLng center; @@ -433,6 +984,9 @@ class _MapSection extends StatelessWidget { final MapController mapController; final bool showTiles; final AddressEntry? address; + final TruckRoute? activeRoute; + final ColonyRoute? selectedColonyRoute; + final List routePoints; @override Widget build(BuildContext context) { @@ -482,6 +1036,16 @@ class _MapSection extends StatelessWidget { urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'flutter_application_1', ), + if (routePoints.length > 1) + PolylineLayer( + polylines: [ + Polyline( + points: routePoints, + strokeWidth: 4, + color: Colors.teal.shade700, + ), + ], + ), MarkerLayer(markers: markers), ], ) @@ -513,6 +1077,20 @@ class _MapSection extends StatelessWidget { style: TextStyle(color: Colors.grey.shade700), ), const SizedBox(height: 4), + Text( + activeRoute == null + ? 'Ruta por defecto no disponible.' + : 'Ruta activa: ${activeRoute!.routeId} - ${activeRoute!.name}', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + if (selectedColonyRoute != null) ...[ + const SizedBox(height: 2), + Text( + 'Horario estimado: ${selectedColonyRoute!.horarioEstimado}', + style: TextStyle(color: Colors.grey.shade700), + ), + ], + const SizedBox(height: 4), Text( truckVisible ? 'El camión está dentro de 20 m.' : 'El camión está fuera de rango.', style: const TextStyle(fontWeight: FontWeight.w700), @@ -581,6 +1159,9 @@ class _CalendarSection extends StatelessWidget { required this.selectedDay, required this.calendarFormat, required this.notes, + required this.seedEvents, + required this.selectedSavedNote, + required this.selectedSeedEvents, required this.noteController, required this.onSelectedDay, required this.onFormatChanged, @@ -592,6 +1173,9 @@ class _CalendarSection extends StatelessWidget { final DateTime? selectedDay; final CalendarFormat calendarFormat; final Map notes; + final List seedEvents; + final String? selectedSavedNote; + final List selectedSeedEvents; final TextEditingController noteController; final void Function(DateTime day, DateTime focusedDay) onSelectedDay; final void Function(CalendarFormat format) onFormatChanged; @@ -600,9 +1184,30 @@ class _CalendarSection extends StatelessWidget { DateTime _normalizeDay(DateTime day) => DateTime(day.year, day.month, day.day); + CalendarEventEntry? _seedEventForDay(DateTime day) { + final normalizedDay = _normalizeDay(day); + for (final event in seedEvents) { + if (event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) { + return event; + } + } + return null; + } + @override Widget build(BuildContext context) { final selectedNormalized = selectedDay == null ? null : _normalizeDay(selectedDay!); + final carouselDays = {}; + if (selectedNormalized != null) { + carouselDays.add(selectedNormalized); + } + for (final day in notes.keys) { + carouselDays.add(_normalizeDay(day)); + } + for (final event in seedEvents) { + carouselDays.add(_normalizeDay(event.date)); + } + final carouselDayList = carouselDays.toList()..sort((a, b) => a.compareTo(b)); return Container( color: const Color(0xFFF8FAFC), @@ -622,25 +1227,54 @@ class _CalendarSection extends StatelessWidget { focusedDay: focusedDay, calendarFormat: calendarFormat, selectedDayPredicate: (day) => isSameDay(selectedDay, day), - eventLoader: (day) => notes.containsKey(_normalizeDay(day)) ? [notes[_normalizeDay(day)]!] : [], + eventLoader: (day) { + final normalizedDay = _normalizeDay(day); + final userNote = notes[normalizedDay]; + final seededTitles = seedEvents + .where((event) => event.date.year == normalizedDay.year && event.date.month == normalizedDay.month && event.date.day == normalizedDay.day) + .map((event) => event.title) + .toList(growable: false); + final events = []; + if (userNote != null && userNote.isNotEmpty) { + events.add(userNote); + } + events.addAll(seededTitles); + return events; + }, onDaySelected: onSelectedDay, onFormatChanged: onFormatChanged, onPageChanged: onPageChanged, calendarBuilders: CalendarBuilders( defaultBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; - if (note == null) { - return null; - } - return _CalendarDayCell(day: day, note: note, selected: false); + final seedEvent = _seedEventForDay(day); + return _CalendarDayCell( + day: day, + note: note, + seededTitle: seedEvent?.title, + selected: false, + ); }, selectedBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; - return _CalendarDayCell(day: day, note: note, selected: true); + final seedEvent = _seedEventForDay(day); + return _CalendarDayCell( + day: day, + note: note, + seededTitle: seedEvent?.title, + selected: true, + ); }, todayBuilder: (context, day, focusedDay) { final note = notes[_normalizeDay(day)]; - return _CalendarDayCell(day: day, note: note, selected: false, today: true); + final seedEvent = _seedEventForDay(day); + return _CalendarDayCell( + day: day, + note: note, + seededTitle: seedEvent?.title, + selected: false, + today: true, + ); }, ), ), @@ -664,10 +1298,97 @@ class _CalendarSection extends StatelessWidget { const SizedBox(height: 12), Text( selectedNormalized == null - ? 'Selecciona un día para escribir.' - : 'Día seleccionado: ${selectedNormalized.day}/${selectedNormalized.month}/${selectedNormalized.year}', + ? 'Desliza el carrusel para ver las fechas importantes.' + : 'Fecha seleccionada: ${selectedNormalized.day}/${selectedNormalized.month}/${selectedNormalized.year}', style: TextStyle(color: Colors.grey.shade700), ), + const SizedBox(height: 12), + if (carouselDayList.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('No hay fechas importantes registradas todavía.'), + ) + else + SizedBox( + height: 170, + child: PageView.builder( + itemCount: carouselDayList.length, + onPageChanged: (index) { + final day = carouselDayList[index]; + onSelectedDay(day, day); + }, + itemBuilder: (context, index) { + final day = carouselDayList[index]; + final note = notes[day]; + final events = seedEvents + .where((event) => event.date.year == day.year && event.date.month == day.month && event.date.day == day.day) + .toList(growable: false); + final isSelected = selectedNormalized != null && isSameDay(selectedNormalized, day); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Card( + elevation: isSelected ? 10 : 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + color: isSelected ? const Color(0xFFE6FFFB) : Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${day.day}/${day.month}/${day.year}', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Text( + note == null || note.trim().isEmpty + ? 'No hay texto guardado para esta fecha.' + : note, + style: TextStyle(color: Colors.grey.shade800), + ), + const SizedBox(height: 10), + if (events.isEmpty) + Text( + 'Sin evento local para este día.', + style: TextStyle(color: Colors.grey.shade600), + ) + else + ...events.map( + (event) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(event.title, style: const TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(event.description, style: TextStyle(color: Colors.grey.shade700)), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), ], ), ), @@ -682,12 +1403,14 @@ class _CalendarDayCell extends StatelessWidget { const _CalendarDayCell({ required this.day, required this.note, + required this.seededTitle, required this.selected, this.today = false, }); final DateTime day; final String? note; + final String? seededTitle; final bool selected; final bool today; @@ -698,6 +1421,8 @@ class _CalendarDayCell extends StatelessWidget { : today ? Colors.teal.shade100 : Colors.white; + final hasNote = note != null && note!.trim().isNotEmpty; + final hasSeedTitle = seededTitle != null && seededTitle!.trim().isNotEmpty; return Container( margin: const EdgeInsets.all(4), @@ -705,10 +1430,11 @@ class _CalendarDayCell extends StatelessWidget { decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(14), - border: Border.all(color: note == null ? Colors.grey.shade300 : Colors.teal), + border: Border.all(color: hasNote || hasSeedTitle ? Colors.teal : Colors.grey.shade300), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Text( '${day.day}', @@ -717,17 +1443,83 @@ class _CalendarDayCell extends StatelessWidget { fontWeight: FontWeight.w800, ), ), - if (note != null) ...[ + if (hasNote || hasSeedTitle) ...[ const SizedBox(height: 4), - Text( - note!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - color: selected ? Colors.white : Colors.teal.shade900, - fontWeight: FontWeight.w700, + Wrap( + alignment: WrapAlignment.center, + spacing: 4, + runSpacing: 4, + children: [ + if (hasNote) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.teal.shade700, + shape: BoxShape.circle, + ), + ), + if (hasSeedTitle) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.deepOrange.shade700, + shape: BoxShape.circle, + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _SelectedDaySummary extends StatelessWidget { + const _SelectedDaySummary({ + required this.day, + required this.savedNote, + required this.events, + }); + + final DateTime day; + final String? savedNote; + final List events; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFF7DD3FC)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detalle del día ${day.day}/${day.month}/${day.year}', + style: const TextStyle(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Text( + savedNote == null || savedNote!.trim().isEmpty + ? 'No hay texto escrito por el usuario para este día.' + : 'Texto guardado: $savedNote', + style: TextStyle(color: Colors.grey.shade800), + ), + if (events.isNotEmpty) ...[ + const SizedBox(height: 10), + const Text('Eventos del calendario:', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + ...events.map( + (event) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text('• ${event.title}: ${event.description}'), ), ), ], @@ -782,6 +1574,12 @@ class _DataSection extends StatelessWidget { required this.loadingAddresses, required this.addressesError, required this.addresses, + required this.seedData, + required this.activeRoute, + required this.selectedGuideRouteId, + required this.selectedColonyRoute, + required this.now, + required this.onSelectGuideRoute, required this.houseNumberController, required this.coloniaController, required this.streetController, @@ -792,6 +1590,12 @@ class _DataSection extends StatelessWidget { final bool loadingAddresses; final String? addressesError; final List addresses; + final LocalSeedData? seedData; + final TruckRoute? activeRoute; + final String? selectedGuideRouteId; + final ColonyRoute? selectedColonyRoute; + final DateTime now; + final void Function(String routeId) onSelectGuideRoute; final TextEditingController houseNumberController; final TextEditingController coloniaController; final TextEditingController streetController; @@ -816,6 +1620,12 @@ class _DataSection extends StatelessWidget { const SizedBox(height: 12), _ProfileRow(label: 'Nombre', value: session.displayName), _ProfileRow(label: 'Correo', value: session.email), + if (selectedColonyRoute != null) ...[ + const SizedBox(height: 8), + _ProfileRow(label: 'Colonia', value: selectedColonyRoute!.colonia), + _ProfileRow(label: 'Ruta', value: selectedColonyRoute!.routeId), + _ProfileRow(label: 'Horario', value: selectedColonyRoute!.horarioEstimado), + ], ], ), ), @@ -859,6 +1669,74 @@ class _DataSection extends StatelessWidget { ), ), const SizedBox(height: 16), + Card( + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Guía de rutas', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 12), + if (seedData == null || seedData!.routes.isEmpty) + const Text('No hay rutas locales disponibles.'), + if (seedData != null && seedData!.routes.isNotEmpty) + ...seedData!.routes.map( + (route) { + final isSelected = route.routeId == selectedGuideRouteId; + final guide = seedData!.guideForRouteId(route.routeId); + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => onSelectGuideRoute(route.routeId), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + route.routeId == activeRoute?.routeId ? Icons.route : Icons.alt_route, + color: route.routeId == activeRoute?.routeId ? Colors.teal : Colors.grey.shade700, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${route.routeId} - ${route.name}'), + const SizedBox(height: 4), + Text( + guide == null + ? 'Camión ${route.truckId} • ${route.status} • ${route.positions.length} puntos' + : 'Camión ${route.truckId} • ${guide.wasteType} • ${formatScheduleAsAmPm(guide.schedule)}', + style: TextStyle(color: Colors.grey.shade700), + ), + ], + ), + ), + Icon(isSelected ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down), + ], + ), + if (isSelected && guide != null) ...[ + const SizedBox(height: 14), + _RouteGuideDetailContent(guide: guide, now: now), + ], + ], + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), Card( elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), @@ -915,6 +1793,43 @@ class _ProfileRow extends StatelessWidget { } } +class _RouteGuideDetailContent extends StatelessWidget { + const _RouteGuideDetailContent({required this.guide, required this.now}); + + final RouteGuideEntry guide; + final DateTime now; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProfileRow(label: 'Ruta', value: guide.routeId), + _ProfileRow(label: 'Tipo', value: guide.wasteType), + _ProfileRow(label: 'Horario', value: formatScheduleAsAmPm(guide.schedule)), + _ProfileRow(label: 'Días', value: guide.days.join(', ')), + const SizedBox(height: 6), + Text( + 'Cronómetro: ${guideCountdownText(guide, now)}', + style: const TextStyle(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 6), + Text(guide.note, style: TextStyle(color: Colors.grey.shade700, height: 1.35)), + ], + ), + ); + } + +} + class _AppNotification { _AppNotification({required this.message, required this.timestamp}); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 67de4b8..d64ba4e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -95,8 +95,8 @@ class HomeScreen extends StatelessWidget { const SizedBox(height: 12), _InfoTile( icon: Icons.storage_outlined, - title: 'Persistencia en PostgreSQL', - subtitle: 'La dirección se envió al backend con el token de sesión para almacenarla en la base de datos.', + title: 'Persistencia local', + subtitle: 'La información se conserva dentro de la app usando los JSON y el almacenamiento local.', ), const SizedBox(height: 24), FilledButton.icon( diff --git a/lib/services/address_repository.dart b/lib/services/address_repository.dart index 21b4749..42acb50 100644 --- a/lib/services/address_repository.dart +++ b/lib/services/address_repository.dart @@ -1,6 +1,7 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; -import '../app_config.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + import '../models/address_entry.dart'; import '../models/address_record.dart'; import '../models/auth_session.dart'; @@ -14,68 +15,61 @@ abstract class AddressRepository { Future> getMyAddresses({required AuthSession session}); } -class HttpAddressRepository implements AddressRepository { - const HttpAddressRepository({http.Client? client}) : _client = client; - - final http.Client? _client; +class LocalAddressRepository implements AddressRepository { + const LocalAddressRepository(); + static const String _localAddressPrefix = 'local_addresses_'; @override Future saveAddress({ required AuthSession session, required AddressEntry address, }) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses'); - - late final http.Response response; - try { - response = await (_client ?? http.Client()).post( - uri, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': 'Bearer ${session.token}', - }, - body: jsonEncode(address.toJson()), - ); - } catch (_) { - throw AddressException( - 'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.', - ); - } - - if (response.statusCode < 200 || response.statusCode >= 300) { - final payload = jsonDecode(response.body); - final message = payload['message']?.toString() ?? 'Error al guardar la dirección.'; - throw AddressException(message); - } + await _persistLocalAddress(session: session, address: address); } @override Future> getMyAddresses({required AuthSession session}) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}/addresses/me'); + return _loadLocalAddresses(session); + } - late final http.Response response; - try { - response = await (_client ?? http.Client()).get( - uri, - headers: { - 'Accept': 'application/json', - 'Authorization': 'Bearer ${session.token}', - }, - ); - } catch (_) { - throw AddressException( - 'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.', - ); + Future _persistLocalAddress({required AuthSession session, required AddressEntry address}) async { + final prefs = await SharedPreferences.getInstance(); + final key = '$_localAddressPrefix${session.email.toLowerCase()}'; + final existing = await _loadLocalAddresses(session); + final records = [ + AddressRecord( + id: DateTime.now().millisecondsSinceEpoch, + houseNumber: address.houseNumber, + colonia: address.colonia, + street: address.street, + ), + ...existing, + ]; + + final encoded = jsonEncode( + records + .map( + (record) => { + 'id': record.id, + 'houseNumber': record.houseNumber, + 'colonia': record.colonia, + 'street': record.street, + }, + ) + .toList(growable: false), + ); + await prefs.setString(key, encoded); + } + + Future> _loadLocalAddresses(AuthSession session) async { + final prefs = await SharedPreferences.getInstance(); + final key = '$_localAddressPrefix${session.email.toLowerCase()}'; + final raw = prefs.getString(key); + if (raw == null || raw.trim().isEmpty) { + return []; } - if (response.statusCode < 200 || response.statusCode >= 300) { - final payload = jsonDecode(response.body); - final message = payload['message']?.toString() ?? 'Error al obtener las direcciones.'; - throw AddressException(message); - } - - final decoded = jsonDecode(response.body); + final decoded = jsonDecode(raw); if (decoded is! List) { return []; } diff --git a/lib/services/auth_repository.dart b/lib/services/auth_repository.dart index d2e0927..776238e 100644 --- a/lib/services/auth_repository.dart +++ b/lib/services/auth_repository.dart @@ -1,10 +1,9 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; -import '../app_config.dart'; import '../models/auth_session.dart'; +import 'local_seed_repository.dart'; abstract class AuthRepository { Future restoreSession(); @@ -13,18 +12,31 @@ abstract class AuthRepository { Future signOut(); } -class HttpAuthRepository implements AuthRepository { - const HttpAuthRepository({http.Client? client}) : _client = client; - - final http.Client? _client; +class LocalAuthRepository implements AuthRepository { + const LocalAuthRepository(); static const String _tokenKey = 'auth_token'; static const String _emailKey = 'auth_email'; static const String _nameKey = 'auth_name'; + static const String _sessionKey = 'auth_session_json'; + static const String _usersKey = 'auth_users_json'; @override Future restoreSession() async { final prefs = await SharedPreferences.getInstance(); + final sessionJson = prefs.getString(_sessionKey); + if (sessionJson != null && sessionJson.trim().isNotEmpty) { + final decoded = jsonDecode(sessionJson); + if (decoded is Map) { + final token = decoded['token']?.toString(); + final email = decoded['email']?.toString(); + final name = decoded['displayName']?.toString(); + if (token != null && email != null && token.isNotEmpty && email.isNotEmpty) { + return AuthSession(token: token, email: email, displayName: name ?? email); + } + } + } + final token = prefs.getString(_tokenKey); final email = prefs.getString(_emailKey); final name = prefs.getString(_nameKey); @@ -53,44 +65,71 @@ class HttpAuthRepository implements AuthRepository { } Future _authenticate({required String endpoint, required Map body}) async { - final uri = Uri.parse('${AppConfig.apiBaseUrl}$endpoint'); + final email = body['email']?.toString().trim(); + final password = body['password']?.toString(); + if (email == null || password == null || email.isEmpty) { + throw AuthException('Ingresa correo y contraseña válidos.'); + } - late final http.Response response; - try { - response = await (_client ?? http.Client()).post( - uri, - headers: const { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: jsonEncode(body), + final seedData = await LocalSeedRepository.instance.load(); + final profile = seedData.profileForCredentials(email, password); + if (profile != null) { + final session = AuthSession( + token: 'local-${profile.email}', + email: profile.email, + displayName: profile.name, ); - } catch (_) { - throw AuthException( - 'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}. Verifica que el servicio esté activo.', + await _persistSession(session); + return session; + } + + final localUsers = await _loadLocalUsers(); + final normalizedEmail = email.toLowerCase(); + final localMatch = localUsers.where((user) { + final userEmail = user['email']?.toString().trim().toLowerCase(); + final userPassword = user['password']?.toString(); + return userEmail == normalizedEmail && userPassword == password; + }).toList(growable: false); + + if (localMatch.isNotEmpty) { + final user = localMatch.first; + final displayName = user['name']?.toString().trim(); + final session = AuthSession( + token: 'local-${email.toLowerCase()}', + email: email, + displayName: displayName == null || displayName.isEmpty ? email : displayName, ); + await _persistSession(session); + return session; } - final Map payload = _decodeJson(response.body); - if (response.statusCode < 200 || response.statusCode >= 300) { - final message = payload['message']?.toString() ?? 'Credenciales inválidas o usuario no disponible.'; - throw AuthException(message); + if (endpoint == '/auth/register') { + final displayName = body['name']?.toString().trim(); + final alreadyInSeed = seedData.profileForCredentials(email, password) != null || + seedData.demoProfiles.any((profile) => profile.email.trim().toLowerCase() == normalizedEmail); + final alreadyInLocal = localUsers.any((user) => user['email']?.toString().trim().toLowerCase() == normalizedEmail); + if (alreadyInSeed || alreadyInLocal) { + throw AuthException('Este correo ya está registrado.'); + } + + final createdUser = { + 'name': displayName == null || displayName.isEmpty ? email : displayName, + 'email': email, + 'password': password, + 'createdAt': DateTime.now().toIso8601String(), + }; + await _saveLocalUsers(>[...localUsers, createdUser]); + + final session = AuthSession( + token: 'local-$email', + email: email, + displayName: displayName == null || displayName.isEmpty ? email : displayName, + ); + await _persistSession(session); + return session; } - final token = payload['token']?.toString(); - if (token == null || token.isEmpty) { - throw AuthException('El backend respondió sin token de sesión.'); - } - - final user = payload['user'] is Map - ? payload['user'] as Map - : {}; - final emailValue = (user['email'] ?? body['email'])?.toString() ?? body['email'].toString(); - final displayName = (user['name'] ?? user['fullName'] ?? body['name'] ?? emailValue).toString(); - - final session = AuthSession(token: token, email: emailValue, displayName: displayName); - await _persistSession(session); - return session; + throw AuthException('Usuario o contraseña no encontrados en los perfiles locales.'); } Future _persistSession(AuthSession session) async { @@ -98,19 +137,36 @@ class HttpAuthRepository implements AuthRepository { await prefs.setString(_tokenKey, session.token); await prefs.setString(_emailKey, session.email); await prefs.setString(_nameKey, session.displayName); + await prefs.setString( + _sessionKey, + jsonEncode( + { + 'token': session.token, + 'email': session.email, + 'displayName': session.displayName, + }, + ), + ); } - Map _decodeJson(String responseBody) { - if (responseBody.trim().isEmpty) { - return {}; + Future>> _loadLocalUsers() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_usersKey); + if (raw == null || raw.trim().isEmpty) { + return >[]; } - final decoded = jsonDecode(responseBody); - if (decoded is Map) { - return decoded; + final decoded = jsonDecode(raw); + if (decoded is! List) { + return >[]; } - return {}; + return decoded.whereType>().toList(growable: false); + } + + Future _saveLocalUsers(List> users) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_usersKey, jsonEncode(users)); } @override @@ -119,6 +175,7 @@ class HttpAuthRepository implements AuthRepository { await prefs.remove(_tokenKey); await prefs.remove(_emailKey); await prefs.remove(_nameKey); + await prefs.remove(_sessionKey); } } diff --git a/lib/services/local_seed_repository.dart b/lib/services/local_seed_repository.dart new file mode 100644 index 0000000..fea2fd6 --- /dev/null +++ b/lib/services/local_seed_repository.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart' show rootBundle; + +import '../models/colony_route.dart'; +import '../models/calendar_event_entry.dart'; +import '../models/demo_profile.dart'; +import '../models/route_guide_entry.dart'; +import '../models/route_notification.dart'; +import '../models/truck_route.dart'; + +class LocalSeedRepository { + LocalSeedRepository._(); + + static final LocalSeedRepository instance = LocalSeedRepository._(); + Future? _cachedLoad; + + Future load() { + return _cachedLoad ??= _loadInternal(); + } + + Future _loadInternal() async { + try { + final routesJson = await rootBundle.loadString('assets/json/rutas.json'); + final notificationsJson = await rootBundle.loadString('assets/json/notificaciones.json'); + final colonyRoutesJson = await rootBundle.loadString('assets/json/colonias-rutas.json'); + final profilesJson = await rootBundle.loadString('assets/json/perfiles.json'); + final calendarEventsJson = await rootBundle.loadString('assets/json/calendario.json'); + final routeGuidesJson = await rootBundle.loadString('assets/json/guia-rutas.json'); + + final routes = _decodeList(routesJson).map(TruckRoute.fromJson).toList(growable: false); + final notifications = _decodeList(notificationsJson).map(RouteNotification.fromJson).toList(growable: false); + final colonyRoutes = _decodeList(colonyRoutesJson).map(ColonyRoute.fromJson).toList(growable: false); + final profiles = _decodeList(profilesJson).map(DemoProfile.fromJson).toList(growable: false); + final calendarEvents = _decodeList(calendarEventsJson).map(CalendarEventEntry.fromJson).toList(growable: false); + final routeGuides = _decodeList(routeGuidesJson).map(RouteGuideEntry.fromJson).toList(growable: false); + + return LocalSeedData( + routes: routes, + notifications: notifications, + colonyRoutes: colonyRoutes, + demoProfiles: profiles, + calendarEvents: calendarEvents, + routeGuides: routeGuides, + ); + } catch (_) { + return LocalSeedData.empty(); + } + } + + List> _decodeList(String responseBody) { + final decoded = jsonDecode(responseBody); + if (decoded is! List) { + return >[]; + } + + return decoded.whereType>().toList(growable: false); + } +} + +class LocalSeedData { + const LocalSeedData({ + required this.routes, + required this.notifications, + required this.colonyRoutes, + required this.demoProfiles, + required this.calendarEvents, + required this.routeGuides, + }); + + final List routes; + final List notifications; + final List colonyRoutes; + final List demoProfiles; + final List calendarEvents; + final List routeGuides; + + const LocalSeedData.empty() + : routes = const [], + notifications = const [], + colonyRoutes = const [], + demoProfiles = const [], + calendarEvents = const [], + routeGuides = const []; + + TruckRoute? get defaultRoute => routes.isEmpty ? null : routes.first; + + TruckRoute? routeForColonia(String? colonia) { + if (colonia == null || colonia.trim().isEmpty) { + return defaultRoute; + } + + final routeId = colonyRouteForColonia(colonia)?.routeId; + if (routeId == null) { + return defaultRoute; + } + + return routeById(routeId) ?? defaultRoute; + } + + ColonyRoute? colonyRouteForColonia(String colonia) { + final normalized = colonia.trim().toLowerCase(); + for (final item in colonyRoutes) { + if (item.colonia.trim().toLowerCase() == normalized) { + return item; + } + } + return null; + } + + TruckRoute? routeById(String routeId) { + for (final route in routes) { + if (route.routeId == routeId) { + return route; + } + } + return null; + } + + DemoProfile? profileForCredentials(String email, String password) { + final normalizedEmail = email.trim().toLowerCase(); + for (final profile in demoProfiles) { + if (profile.email.trim().toLowerCase() == normalizedEmail && profile.password == password) { + return profile; + } + } + return null; + } + + DemoProfile? profileForColonia(String? colonia) { + if (colonia == null || colonia.trim().isEmpty) { + return demoProfiles.isEmpty ? null : demoProfiles.first; + } + + final normalized = colonia.trim().toLowerCase(); + for (final profile in demoProfiles) { + if (profile.colonia.trim().toLowerCase() == normalized) { + return profile; + } + } + return demoProfiles.isEmpty ? null : demoProfiles.first; + } + + List eventsForDay(DateTime day) { + final normalized = DateTime(day.year, day.month, day.day); + return calendarEvents + .where((event) => event.date.year == normalized.year && event.date.month == normalized.month && event.date.day == normalized.day) + .toList(growable: false); + } + + RouteGuideEntry? guideForRouteId(String routeId) { + for (final guide in routeGuides) { + if (guide.routeId == routeId) { + return guide; + } + } + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e84ad01..1d15a15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,9 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/json/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/widget_test.dart b/test/widget_test.dart index 317314c..9153f04 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -28,6 +28,7 @@ void main() { await tester.enterText(find.byType(TextFormField).at(0), 'demo@correo.com'); await tester.enterText(find.byType(TextFormField).at(1), '123456'); + await tester.ensureVisible(find.text('Ingresar')); await tester.tap(find.text('Ingresar')); await tester.pumpAndSettle(); @@ -42,7 +43,9 @@ void main() { await tester.pump(const Duration(seconds: 2)); expect(addressRepository.savedAddress, isNotNull); - expect(find.byType(NavigationBar), findsOneWidget); + final hasBottomNavigation = find.byType(NavigationBar).evaluate().isNotEmpty; + final hasNavigationRail = find.byType(NavigationRail).evaluate().isNotEmpty; + expect(hasBottomNavigation || hasNavigationRail, isTrue); expect(find.text('Mapa'), findsOneWidget); expect(find.text('Calendario'), findsOneWidget); expect(find.text('Avisos'), findsOneWidget);