Resolve merge conflicts: README + ignore IDE files
This commit is contained in:
95
.gitignore
vendored
95
.gitignore
vendored
@@ -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)
|
||||
|
||||
68
README.md
68
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)
|
||||
|
||||
44
assets/json/calendario.json
Normal file
44
assets/json/calendario.json
Normal 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"
|
||||
}
|
||||
]
|
||||
9
assets/json/colonias-rutas.json
Normal file
9
assets/json/colonias-rutas.json
Normal 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
107
assets/json/guia-rutas.json
Normal 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."
|
||||
}
|
||||
]
|
||||
26
assets/json/notificaciones.json
Normal file
26
assets/json/notificaciones.json
Normal 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."
|
||||
}
|
||||
}
|
||||
]
|
||||
6
assets/json/perfiles.json
Normal file
6
assets/json/perfiles.json
Normal 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
242
assets/json/rutas.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
60
lib/app.dart
60
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<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(),
|
||||
),
|
||||
authRepository: authRepository,
|
||||
addressRepository: addressRepository,
|
||||
enableLiveFeatures: enableLiveFeatures,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
lib/models/calendar_event_entry.dart
Normal file
22
lib/models/calendar_event_entry.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/models/colony_route.dart
Normal file
19
lib/models/colony_route.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/models/demo_profile.dart
Normal file
25
lib/models/demo_profile.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/models/route_guide_entry.dart
Normal file
30
lib/models/route_guide_entry.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/models/route_notification.dart
Normal file
26
lib/models/route_notification.dart
Normal 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() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/models/route_position.dart
Normal file
25
lib/models/route_position.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/models/truck_route.dart
Normal file
35
lib/models/truck_route.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}',
|
||||
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),
|
||||
);
|
||||
} catch (_) {
|
||||
throw AddressException(
|
||||
'No se pudo conectar con el backend en ${AppConfig.apiBaseUrl}.',
|
||||
);
|
||||
await prefs.setString(key, encoded);
|
||||
}
|
||||
|
||||
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);
|
||||
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>[];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return <AddressRecord>[];
|
||||
}
|
||||
|
||||
@@ -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,64 +65,108 @@ 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.',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
throw AuthException('Usuario o contraseña no encontrados en los perfiles locales.');
|
||||
}
|
||||
|
||||
Future<void> _persistSession(AuthSession session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
159
lib/services/local_seed_repository.dart
Normal file
159
lib/services/local_seed_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user