Resolve merge conflicts: README + ignore IDE files

This commit is contained in:
David
2026-05-23 07:11:33 -06:00
parent abfbb255fe
commit 6ff72c738d
27 changed files with 2123 additions and 335 deletions

95
.gitignore vendored
View File

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

View File

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

View File

@@ -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"
}
]

View File

@@ -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)" }
]

107
assets/json/guia-rutas.json Normal file
View File

@@ -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."
}
]

View File

@@ -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."
}
}
]

View File

@@ -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" }
]

242
assets/json/rutas.json Normal file
View File

@@ -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" }
]
}
]

View File

@@ -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<AuthBootstrap> createState() => _AuthBootstrapState();
}
class _AuthBootstrapState extends State<AuthBootstrap> {
late final Future<AuthSession?> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = widget.authRepository.restoreSession();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<AuthSession?>(
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,
);
}
}

View File

@@ -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;
}

View File

@@ -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<String, dynamic> json) {
return CalendarEventEntry(
date: DateTime.parse(json['date'].toString()),
title: json['title'].toString(),
description: json['description'].toString(),
category: json['category'].toString(),
);
}
}

View File

@@ -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<String, dynamic> json) {
return ColonyRoute(
colonia: json['colonia'].toString(),
routeId: json['routeId'].toString(),
horarioEstimado: json['horarioEstimado'].toString(),
);
}
}

View File

@@ -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<String, dynamic> json) {
return DemoProfile(
name: json['name'].toString(),
email: json['email'].toString(),
password: json['password'].toString(),
colonia: json['colonia'].toString(),
routeId: json['routeId'].toString(),
);
}
}

View File

@@ -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<String> days;
final String note;
factory RouteGuideEntry.fromJson(Map<String, dynamic> json) {
final daysJson = json['days'];
final days = daysJson is List
? daysJson.map((day) => day.toString()).toList(growable: false)
: <String>[];
return RouteGuideEntry(
routeId: json['routeId'].toString(),
wasteType: json['wasteType'].toString(),
schedule: json['schedule'].toString(),
days: days,
note: json['note'].toString(),
);
}
}

View File

@@ -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<String, dynamic> json) {
final payload = json['pushPayload'] is Map<String, dynamic>
? json['pushPayload'] as Map<String, dynamic>
: <String, dynamic>{};
return RouteNotification(
triggerEvent: json['triggerEvent'].toString(),
condition: json['condition'].toString(),
title: payload['title']?.toString() ?? '',
body: payload['body']?.toString() ?? '',
);
}
}

View File

@@ -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<String, dynamic> 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()),
);
}
}

View File

@@ -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<RoutePosition> positions;
factory TruckRoute.fromJson(Map<String, dynamic> json) {
final positionsJson = json['positions'];
final positions = positionsJson is List
? positionsJson
.whereType<Map<String, dynamic>>()
.map(RoutePosition.fromJson)
.toList(growable: false)
: <RoutePosition>[];
return TruckRoute(
routeId: json['routeId'].toString(),
name: json['name'].toString(),
truckId: (json['truckId'] as num).toInt(),
status: json['status'].toString(),
positions: positions,
);
}
}

View File

@@ -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<AddressScreen> {
Future<void> _saveAddress() async {
if (!(_formKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -94,7 +100,7 @@ class _AddressScreenState extends State<AddressScreen> {
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<AddressScreen> {
),
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<AddressScreen> {
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<AddressScreen> {
),
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<AddressScreen> {
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<AddressScreen> {
),
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<AddressScreen> {
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<AddressScreen> {
),
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<AddressScreen> {
),
),
const SizedBox(height: 12),
Text(
'Base URL configurada: ${AppConfig.apiBaseUrl}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
),
),

View File

@@ -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<AuthScreen> {
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<AuthScreen> {
super.dispose();
}
Future<void> _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<void> _useDemoProfile(DemoProfile profile) async {
_fillDemoProfile(profile);
await _submit(() {
return widget.authRepository.signIn(
email: profile.email,
password: profile.password,
);
});
}
Future<void> _signIn() async {
if (!(_loginFormKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -61,6 +109,9 @@ class _AuthScreenState extends State<AuthScreen> {
Future<void> _signUp() async {
if (!(_registerFormKey.currentState?.validate() ?? false)) {
setState(() {
_errorMessage = 'Respete los campos';
});
return;
}
@@ -106,7 +157,7 @@ class _AuthScreenState extends State<AuthScreen> {
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<AuthScreen> {
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<AuthScreen> {
),
),
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),
@@ -459,3 +518,46 @@ class _AuthStatusBanner extends StatelessWidget {
);
}
}
class _DemoProfilesSection extends StatelessWidget {
const _DemoProfilesSection({
required this.profiles,
required this.onProfileSelected,
});
final List<DemoProfile> profiles;
final Future<void> 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),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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<List<AddressRecord>> 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<void> 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<List<AddressRecord>> 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<void> _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>[
AddressRecord(
id: DateTime.now().millisecondsSinceEpoch,
houseNumber: address.houseNumber,
colonia: address.colonia,
street: address.street,
),
...existing,
];
final encoded = jsonEncode(
records
.map(
(record) => <String, dynamic>{
'id': record.id,
'houseNumber': record.houseNumber,
'colonia': record.colonia,
'street': record.street,
},
)
.toList(growable: false),
);
await prefs.setString(key, encoded);
}
Future<List<AddressRecord>> _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 <AddressRecord>[];
}
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 <AddressRecord>[];
}

View File

@@ -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<AuthSession?> restoreSession();
@@ -13,18 +12,31 @@ abstract class AuthRepository {
Future<void> 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<AuthSession?> 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<String, dynamic>) {
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<AuthSession> _authenticate({required String endpoint, required Map<String, dynamic> 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 <String, String>{
'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<String, dynamic> 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 = <String, dynamic>{
'name': displayName == null || displayName.isEmpty ? email : displayName,
'email': email,
'password': password,
'createdAt': DateTime.now().toIso8601String(),
};
await _saveLocalUsers(<Map<String, dynamic>>[...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<String, dynamic>
? payload['user'] as Map<String, dynamic>
: <String, dynamic>{};
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<void> _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(
<String, dynamic>{
'token': session.token,
'email': session.email,
'displayName': session.displayName,
},
),
);
}
Map<String, dynamic> _decodeJson(String responseBody) {
if (responseBody.trim().isEmpty) {
return <String, dynamic>{};
Future<List<Map<String, dynamic>>> _loadLocalUsers() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_usersKey);
if (raw == null || raw.trim().isEmpty) {
return <Map<String, dynamic>>[];
}
final decoded = jsonDecode(responseBody);
if (decoded is Map<String, dynamic>) {
return decoded;
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <Map<String, dynamic>>[];
}
return <String, dynamic>{};
return decoded.whereType<Map<String, dynamic>>().toList(growable: false);
}
Future<void> _saveLocalUsers(List<Map<String, dynamic>> 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);
}
}

View File

@@ -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<LocalSeedData>? _cachedLoad;
Future<LocalSeedData> load() {
return _cachedLoad ??= _loadInternal();
}
Future<LocalSeedData> _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<Map<String, dynamic>> _decodeList(String responseBody) {
final decoded = jsonDecode(responseBody);
if (decoded is! List) {
return <Map<String, dynamic>>[];
}
return decoded.whereType<Map<String, dynamic>>().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<TruckRoute> routes;
final List<RouteNotification> notifications;
final List<ColonyRoute> colonyRoutes;
final List<DemoProfile> demoProfiles;
final List<CalendarEventEntry> calendarEvents;
final List<RouteGuideEntry> routeGuides;
const LocalSeedData.empty()
: routes = const <TruckRoute>[],
notifications = const <RouteNotification>[],
colonyRoutes = const <ColonyRoute>[],
demoProfiles = const <DemoProfile>[],
calendarEvents = const <CalendarEventEntry>[],
routeGuides = const <RouteGuideEntry>[];
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<CalendarEventEntry> 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;
}
}

View File

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

View File

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