diff --git a/animations/blink feliz 1.mp4 b/animations/blink feliz 1.mp4 new file mode 100644 index 0000000..ae88390 Binary files /dev/null and b/animations/blink feliz 1.mp4 differ diff --git a/animations/blink saludo.mp4 b/animations/blink saludo.mp4 new file mode 100644 index 0000000..d0d386b Binary files /dev/null and b/animations/blink saludo.mp4 differ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b5651d1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +secrets/ +*.pyc +__pycache__/ +.venv/ \ No newline at end of file diff --git a/backend/FIREBASE_SETUP.md b/backend/FIREBASE_SETUP.md new file mode 100644 index 0000000..85acc2d --- /dev/null +++ b/backend/FIREBASE_SETUP.md @@ -0,0 +1,46 @@ +# Firebase setup for Recolecta project + +This document explains the manual steps to create a Firebase project, register Android/iOS apps, obtain the Admin SDK credentials for backend (FCM), and connect the Flutter app. + +1) Create a Firebase project + - Go to https://console.firebase.google.com/ and create a new project (e.g., `recolecta-demo`). + +2) Register Android app + - In the Firebase console, add an Android app. Use the app package name that matches your Flutter app (check `android/app/src/main/AndroidManifest.xml` or `android/app/build.gradle` `applicationId`). + - Download `google-services.json` and place it in the Flutter project at `recolecta_app/android/app/google-services.json`. + - Update `android/build.gradle` and `android/app/build.gradle` if needed (standard Flutter - Firebase steps). See https://firebase.flutter.dev/docs/overview/#installation + +3) Register iOS app + - Add an iOS app in Firebase, set the iOS bundle id from your Flutter project (check `ios/Runner.xcodeproj`), download `GoogleService-Info.plist` and add it to `recolecta_app/ios/Runner/GoogleService-Info.plist` (open Xcode and add to Runner target). + +4) Enable Cloud Messaging + - In Firebase console go to Cloud Messaging and ensure that your app is configured. + +5) Obtain Admin SDK credentials (Backend) + - In Firebase Console: Project Settings → Service Accounts → Generate new private key. + - This downloads a JSON file like `recolecta-adminsdk-xxxxx.json`. + - Copy that file to the backend secrets folder: `backend/secrets/firebase-adminsdk.json` (create `backend/secrets/` if missing). + - IMPORTANT: do NOT commit this file to git. `backend/.gitignore` already excludes `secrets/`. + +6) Set environment variable for backend + - Set `FIREBASE_CREDENTIALS_PATH` to the path where you placed the JSON, example in `.env`: + +``` +FIREBASE_CREDENTIALS_PATH=backend/secrets/firebase-adminsdk.json +``` + +7) Flutter configuration (pubspec) + - Add `firebase_core` and `firebase_messaging` to `recolecta_app/pubspec.yaml` and run `flutter pub get`. + - Initialize Firebase in Flutter `main()` per docs: `WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp();` + - Subscribe the citizen client to the route topic after confirming their `routeId`: + +```dart +FirebaseMessaging.instance.subscribeToTopic('topic_RUTA-01'); +``` + +8) Testing push (backend) + - The backend contains `app/services/notifications.py` which will use the Admin SDK if `FIREBASE_CREDENTIALS_PATH` is set and the file exists. Otherwise it falls back to a mock that prints messages. + - Start backend and trigger `POST /simulate/tick` to see mock pushes or real pushes if Admin SDK is configured. + +9) Security note + - Keep the Admin SDK JSON secret. Use environment-managed secrets in production (Cloud Run secret manager, etc.). diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7c64ec9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,27 @@ +# Backend (FastAPI) - Minimal scaffold + +Este directorio contiene un scaffold mínimo para la API de simulación. + +Requisitos + +- Python 3.9+ +- Crear un virtualenv e instalar dependencias: + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +Ejecutar la app + +```bash +# desde la carpeta backend +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Endpoints útiles + +- `GET /colonias` — lista de colonias (mapea a `routeId`) +- `GET /eta?colonia=Zona%20Centro` — devuelve `mensaje` y `status` textual (sin coordenadas) +- `POST /simulate/tick` — avanza la simulación un paso y devuelve los eventos disparados diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..7cdf741 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Inicializa el paquete db \ No newline at end of file diff --git a/backend/app/api/eta.py b/backend/app/api/eta.py new file mode 100644 index 0000000..c616e50 --- /dev/null +++ b/backend/app/api/eta.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, HTTPException +from typing import Optional +from app.services import simulation + +router = APIRouter() + + +@router.get("/colonias") +def list_colonias(): + return simulation.get_colonias() + + +@router.get("/eta") +def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None): + # Resolver routeId a partir de colonia si es necesario + if routeId is None: + if colonia is None: + raise HTTPException(status_code=400, detail="colonia or routeId required") + mapping = simulation.get_colonias() + match = next((c for c in mapping if c.get("colonia","").lower() == colonia.lower()), None) + if not match: + raise HTTPException(status_code=404, detail="colonia not found") + routeId = match["routeId"] + + pos = simulation.get_route_position(routeId) + status = simulation.get_route_status(routeId) + if pos is None: + raise HTTPException(status_code=404, detail="route not found") + + if pos < 4: + mensaje = "El camión va en camino a tu sector" + elif pos == 4: + mensaje = "Llega en aproximadamente 15 minutos" + elif pos < 8: + mensaje = "Está atendiendo tu zona; saca tus bolsas" + else: + mensaje = "Servicio del día finalizado" + + return {"mensaje": mensaje, "status": status, "routeId": routeId} + + +@router.post("/simulate/tick") +def simulate_tick(): + events = simulation.tick() + return {"events": events} diff --git a/backend/app/data/colonias-rutas.json b/backend/app/data/colonias-rutas.json index a78c21e..9c0dae6 100644 --- a/backend/app/data/colonias-rutas.json +++ b/backend/app/data/colonias-rutas.json @@ -1,9 +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)" } + {"colonia": "Zona Centro", "routeId": "RUTA-01", "turno": "Matutino", "horario_estimado": "07:00 - 09:00"}, + {"colonia": "Las Arboledas", "routeId": "RUTA-01", "turno": "Matutino", "horario_estimado": "08:30 - 10:30"}, + {"colonia": "San Juanico", "routeId": "RUTA-03", "turno": "Matutino", "horario_estimado": "07:00 - 09:00"}, + {"colonia": "Los Olivos", "routeId": "RUTA-04", "turno": "Matutino", "horario_estimado": "09:00 - 11:00"}, + {"colonia": "Rancho Seco", "routeId": "RUTA-05", "turno": "Vespertino", "horario_estimado": "18:00 - 20:00"}, + {"colonia": "Las Insurgentes", "routeId": "RUTA-12", "turno": "Matutino", "horario_estimado": "10:00 - 12:00"}, + {"colonia": "Trojes", "routeId": "RUTA-13", "turno": "Matutino", "horario_estimado": "11:00 - 13:00"} ] \ No newline at end of file diff --git a/backend/app/data/notificaciones.json b/backend/app/data/notificaciones.json index ee20c60..70363e0 100644 --- a/backend/app/data/notificaciones.json +++ b/backend/app/data/notificaciones.json @@ -1,26 +1,14 @@ [ { "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." - } + "pushPayload": {"title": "Ruta Iniciada", "body": "El camión salió del relleno rumbo a tu sector."} }, { "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." - } + "pushPayload": {"title": "Camión Cerca", "body": "A menos de 15 min; saca tus bolsas."} }, { "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." - } + "pushPayload": {"title": "Servicio Finalizado", "body": "El servicio del día en tu zona ha concluido."} } ] \ No newline at end of file diff --git a/backend/app/data/rutas.json b/backend/app/data/rutas.json index aeac0ba..82452b3 100644 --- a/backend/app/data/rutas.json +++ b/backend/app/data/rutas.json @@ -1,242 +1,57 @@ [ { "routeId": "RUTA-01", - "name": "Zona Centro - Las Arboledas", "truckId": 101, - "status": "EN_RUTA", + "turno": "matutino", + "status": "PENDIENTE", "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" } + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 40, "ts": "2026-05-01T08:00:00Z"}, + {"positionId": 2, "lat": 20.5150, "lng": -100.9000, "speed": 35, "ts": "2026-05-01T08:10:00Z"}, + {"positionId": 3, "lat": 20.5200, "lng": -100.8950, "speed": 20, "ts": "2026-05-01T08:20:00Z"}, + {"positionId": 4, "lat": 20.5250, "lng": -100.8900, "speed": 15, "ts": "2026-05-01T08:30:00Z"}, + {"positionId": 5, "lat": 20.5300, "lng": -100.8850, "speed": 0, "ts": "2026-05-01T08:40:00Z"}, + {"positionId": 6, "lat": 20.5250, "lng": -100.8900, "speed": 25, "ts": "2026-05-01T08:50:00Z"}, + {"positionId": 7, "lat": 20.5150, "lng": -100.9000, "speed": 30, "ts": "2026-05-01T09:00:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 0, "ts": "2026-05-01T09:10:00Z"} ] }, { "routeId": "RUTA-05", - "name": "Sector Sur - Rancho Seco", "truckId": 105, - "status": "EN_RUTA", + "turno": "vespertino", + "status": "PENDIENTE", "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" } + {"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 40, "ts": "2026-05-01T15:00:00Z"}, + {"positionId": 4, "lat": 20.5250, "lng": -100.8900, "speed": 15, "ts": "2026-05-01T15:30:00Z"}, + {"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 0, "ts": "2026-05-01T16:10: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-03", + "truckId": 103, + "turno": "matutino", + "status": "PENDIENTE", + "positions": [] }, { - "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-04", + "truckId": 104, + "turno": "matutino", + "status": "PENDIENTE", + "positions": [] }, { "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" } - ] + "turno": "matutino", + "status": "PENDIENTE", + "positions": [] }, { "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" } - ] + "turno": "matutino", + "status": "PENDIENTE", + "positions": [] } ] \ No newline at end of file diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py new file mode 100644 index 0000000..876cc29 --- /dev/null +++ b/backend/app/db/seed.py @@ -0,0 +1,107 @@ +import json +import os +from supabase import create_client, Client + +# Configuración de directorios base +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join(BASE_DIR, "data") +ENV_PATH = os.path.join(os.path.dirname(BASE_DIR), ".env") + +def load_env(path: str): + """Carga variables de entorno de forma manual sin depender de python-dotenv""" + if not os.path.exists(path): + print(f"Advertencia: No se encontró el archivo {path}") + return + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + # Separa por el primer '=' y limpia espacios y comillas + key, val = line.split('=', 1) + os.environ[key.strip()] = val.strip().strip("'").strip('"') + +def load_json(filename: str): + path = os.path.join(DATA_DIR, filename) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +def main(): + print("Iniciando proceso de seeding...") + load_env(ENV_PATH) + + # Es crucial usar SUPABASE_SERVICE_ROLE_KEY para saltar el RLS durante el Seed + URL = os.environ.get("SUPABASE_URL") + KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") + + if not URL or not KEY: + raise ValueError("Error: Asegúrate de tener SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY en el .env") + + supabase: Client = create_client(URL, KEY) + + rutas_data = load_json("rutas.json") + colonias_data = load_json("colonias-rutas.json") + + # 1. Poblar UNITS (Dependencia raíz) + print("1. Poblando tabla 'units'...") + units_to_insert = [] + truck_ids = set() + for r in rutas_data: + tid = r.get("truckId") + if tid and tid not in truck_ids: + truck_ids.add(tid) + units_to_insert.append({ + "id": tid, + "plate": f"GTO-{tid}", # Simulado + "status": "active" + }) + if units_to_insert: + supabase.table("units").upsert(units_to_insert).execute() + + # 2. Poblar ROUTES (Depende de units) + print("2. Poblando tabla 'routes'...") + routes_to_insert = [] + for r in rutas_data: + routes_to_insert.append({ + "id": r["routeId"], + "name": f"Ruta {r['routeId'].split('-')[1]}", + "truck_id": r["truckId"], + "turno": r.get("turno", "matutino"), + "status": r.get("status", "pendiente"), + "current_position_id": 1 + }) + if routes_to_insert: + supabase.table("routes").upsert(routes_to_insert).execute() + + # 3. Poblar ROUTE_POSITIONS (Depende de routes) + print("3. Poblando tabla 'route_positions'...") + positions_to_insert = [] + for r in rutas_data: + for pos in r.get("positions", []): + positions_to_insert.append({ + "route_id": r["routeId"], + "position_id": pos["positionId"], + "lat": pos["lat"], + "lng": pos["lng"], + "speed": pos.get("speed", 0), + "ts": pos.get("ts") + }) + if positions_to_insert: + supabase.table("route_positions").upsert(positions_to_insert).execute() + + # 4. Poblar COLONIAS (Depende de routes) + print("4. Poblando tabla 'colonias'...") + if colonias_data: + colonias_to_insert = [] + for c in colonias_data: + colonias_to_insert.append({ + "nombre": c["colonia"], + "route_id": c["routeId"], + "turno": c["turno"], + "horario_estimado": c["horario_estimado"] + }) + supabase.table("colonias").upsert(colonias_to_insert).execute() + + print("✅ Seed completado con éxito. Base de datos operativa para la app.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2d4e547 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI +from app.api.eta import router as eta_router +from app.services import simulation +from app.services import notifications +import os + +app = FastAPI(title="Recoleccion API") +app.include_router(eta_router) + + +@app.on_event("startup") +async def startup_event(): + # Carga los datos en memoria al iniciar la app + simulation.load_data() + simulation.start_simulation_state() + # Inicializar Firebase Admin si hay credenciales + cred_path = os.environ.get("FIREBASE_CREDENTIALS_PATH", "backend/secrets/firebase-adminsdk.json") + notifications.init_firebase(cred_path) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..4a122b5 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,9 @@ +from .simulation import ( + load_data, + start_simulation_state, + get_colonias, + get_route_position, + get_route_status, + tick, + get_last_events, +) diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..9adf8c3 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,49 @@ +import os +from typing import Dict + +_initialized = False +_use_mock = True + +def init_firebase(credentials_path: str = None): + global _initialized, _use_mock + try: + import firebase_admin + from firebase_admin import credentials, messaging + except Exception: + _use_mock = True + return + + if credentials_path is None: + credentials_path = os.environ.get('FIREBASE_CREDENTIALS_PATH', 'backend/secrets/firebase-adminsdk.json') + + if not os.path.exists(credentials_path): + _use_mock = True + return + + try: + cred = credentials.Certificate(credentials_path) + firebase_admin.initialize_app(cred) + _initialized = True + _use_mock = False + except Exception: + _use_mock = True + + +def send_to_topic(topic: str, payload: Dict): + """Sends a push to an FCM topic. Falls back to mock (prints) if not configured.""" + global _use_mock + if _use_mock: + print(f"[MOCK PUSH] topic={topic} payload={payload}") + return {"mock": True, "topic": topic, "payload": payload} + + try: + from firebase_admin import messaging + message = messaging.Message( + notification=messaging.Notification(title=payload.get('title'), body=payload.get('body')), + topic=topic, + ) + resp = messaging.send(message) + return {"result": resp} + except Exception as e: + print(f"[PUSH ERROR] {e}") + return {"error": str(e)} diff --git a/backend/app/services/simulation.py b/backend/app/services/simulation.py new file mode 100644 index 0000000..72d6299 --- /dev/null +++ b/backend/app/services/simulation.py @@ -0,0 +1,94 @@ +import json +import os +from typing import Dict, List, Optional +from app.services import notifications + +ROOT = os.path.dirname(os.path.dirname(__file__)) # backend/app +DATA_DIR = os.path.join(ROOT, "data") + +ROUTES: List[Dict] = [] +NOTIFS: List[Dict] = [] +COLONIAS: List[Dict] = [] +ESTADO: Dict[str, int] = {} +STATUS: Dict[str, str] = {} +LAST_EVENTS: List[Dict] = [] + + +def _load_json(filename: str): + path = os.path.join(DATA_DIR, filename) + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def load_data(): + global ROUTES, NOTIFS, COLONIAS + ROUTES = _load_json("rutas.json") + NOTIFS = _load_json("notificaciones.json") + COLONIAS = _load_json("colonias-rutas.json") + + +def start_simulation_state(): + """Inicializa el estado (positionId) para cada ruta presente en `rutas.json`.""" + global ESTADO, STATUS + ESTADO = {} + STATUS = {} + for r in ROUTES: + rid = r.get("routeId") + ESTADO[rid] = 1 + STATUS[rid] = r.get("status", "PENDIENTE") + + +def get_colonias(): + return COLONIAS + + +def get_route_position(routeId: str) -> Optional[int]: + return ESTADO.get(routeId) + + +def get_route_status(routeId: str) -> Optional[str]: + return STATUS.get(routeId) + + +def _find_notif(event_name: str) -> Optional[Dict]: + for n in NOTIFS: + if n.get("triggerEvent") == event_name: + return n + return None + + +def tick() -> List[Dict]: + """Avanza todas las rutas en memoria (pos 1..8) y devuelve eventos disparados.""" + global ESTADO, LAST_EVENTS + events = [] + for route_id, pos in list(ESTADO.items()): + if pos < 8: + antes = pos + ahora = pos + 1 + ESTADO[route_id] = ahora + evt = None + if antes == 1 and ahora == 2: + evt = "ROUTE_START" + elif ahora == 4: + evt = "TRUCK_PROXIMITY" + elif ahora == 8: + evt = "ROUTE_COMPLETED" + + if evt: + notif = _find_notif(evt) + payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""} + simulated = {"routeId": route_id, "event": evt, "payload": payload} + events.append(simulated) + LAST_EVENTS.append(simulated) + # Enviar push vía servicio de notificaciones (FCM) o mock + topic = f"topic_{route_id}" + try: + notifications.send_to_topic(topic, payload) + except Exception: + print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}") + + return events + + +def get_last_events() -> List[Dict]: + return LAST_EVENTS[-20:] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..531caba --- /dev/null +++ b/backend/main.py @@ -0,0 +1,36 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI + +# Aquí se importarán los routers en el futuro +# from app.api.routers import auth, addresses, routes, eta + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Maneja el ciclo de vida de la aplicación. + Ideal para arrancar el cron job de simulación (APScheduler). + """ + print("Iniciando aplicación: Backend Sistema de Recolección...") + # TODO: Inicializar APScheduler aquí para avanzar current_position_id (1-8) + yield + print("Apagando aplicación y deteniendo simulador...") + # TODO: Apagar APScheduler + +app = FastAPI( + title="API - Recolección Inteligente y Privada", + description="Backend para el sistema de recolección de residuos con privacidad por diseño.", + version="1.0.0", + lifespan=lifespan +) + +# Endpoints de prueba base +@app.get("/") +def read_root(): + return { + "status": "ok", + "message": "Backend operativo. Regla Innegociable 1: NUNCA se devuelven coordenadas del camión al ciudadano." + } + +@app.get("/health") +def health_check(): + return {"status": "healthy"} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..578b2ff --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.95.0 +uvicorn[standard]>=0.22.0 +firebase-admin>=6.0.0 +apscheduler>=3.10.1 +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +apscheduler==3.10.4 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic-settings==2.2.1 +supabase==2.4.5 +firebase-admin==6.5.0 \ No newline at end of file diff --git a/recolecta_app/views/lib/main.dart b/recolecta_app/views/lib/main.dart new file mode 100644 index 0000000..d891524 --- /dev/null +++ b/recolecta_app/views/lib/main.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'theme/app_theme.dart'; +import 'screens/splash_screen.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + ), + ); + runApp(const RutaVerdeApp()); +} + +class RutaVerdeApp extends StatelessWidget { + const RutaVerdeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'RutaVerde', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + home: const SplashScreen(), + ); + } +} diff --git a/recolecta_app/views/lib/models/models.dart b/recolecta_app/views/lib/models/models.dart new file mode 100644 index 0000000..3313d2c --- /dev/null +++ b/recolecta_app/views/lib/models/models.dart @@ -0,0 +1,196 @@ +// ── Usuario ────────────────────────────────────────────────────────────────── +class UserModel { + final String id; + final String nombre; + final String apellido; + final String email; + final String telefono; + final List casas; + + const UserModel({ + required this.id, + required this.nombre, + required this.apellido, + required this.email, + required this.telefono, + this.casas = const [], + }); + + String get nombreCompleto => '$nombre $apellido'; + String get iniciales => + '${nombre.isNotEmpty ? nombre[0] : ''}${apellido.isNotEmpty ? apellido[0] : ''}' + .toUpperCase(); + + UserModel copyWith({ + String? nombre, + String? apellido, + String? email, + String? telefono, + List? casas, + }) { + return UserModel( + id: id, + nombre: nombre ?? this.nombre, + apellido: apellido ?? this.apellido, + email: email ?? this.email, + telefono: telefono ?? this.telefono, + casas: casas ?? this.casas, + ); + } +} + +// ── Casa ───────────────────────────────────────────────────────────────────── +class HouseModel { + final String id; + final String alias; + final String calle; + final String colonia; + final String codigoPostal; + final double latitud; + final double longitud; + final int radioAlertaMetros; + final bool alertaCercana; + final bool alertaMedia; + final bool recordatorioDiario; + final bool activa; + + const HouseModel({ + required this.id, + this.alias = 'Casa principal', + required this.calle, + required this.colonia, + required this.codigoPostal, + required this.latitud, + required this.longitud, + this.radioAlertaMetros = 200, + this.alertaCercana = true, + this.alertaMedia = false, + this.recordatorioDiario = true, + this.activa = true, + }); + + String get direccionCompleta => '$calle, Col. $colonia, C.P. $codigoPostal'; + + HouseModel copyWith({ + String? alias, + String? calle, + String? colonia, + String? codigoPostal, + double? latitud, + double? longitud, + int? radioAlertaMetros, + bool? alertaCercana, + bool? alertaMedia, + bool? recordatorioDiario, + bool? activa, + }) { + return HouseModel( + id: id, + alias: alias ?? this.alias, + calle: calle ?? this.calle, + colonia: colonia ?? this.colonia, + codigoPostal: codigoPostal ?? this.codigoPostal, + latitud: latitud ?? this.latitud, + longitud: longitud ?? this.longitud, + radioAlertaMetros: radioAlertaMetros ?? this.radioAlertaMetros, + alertaCercana: alertaCercana ?? this.alertaCercana, + alertaMedia: alertaMedia ?? this.alertaMedia, + recordatorioDiario: recordatorioDiario ?? this.recordatorioDiario, + activa: activa ?? this.activa, + ); + } +} + +// ── Camión ─────────────────────────────────────────────────────────────────── +class TruckLocation { + final String id; + final String ruta; + final double latitud; + final double longitud; + final DateTime ultimaActualizacion; + final bool enServicio; + + const TruckLocation({ + required this.id, + required this.ruta, + required this.latitud, + required this.longitud, + required this.ultimaActualizacion, + this.enServicio = true, + }); + + String get tiempoActualizacion { + final diff = DateTime.now().difference(ultimaActualizacion); + if (diff.inSeconds < 60) return 'Hace ${diff.inSeconds} s'; + if (diff.inMinutes < 60) return 'Hace ${diff.inMinutes} min'; + return 'Hace ${diff.inHours} h'; + } +} + +// ── Alerta ─────────────────────────────────────────────────────────────────── +enum TipoAlerta { cercana, media, recordatorio } + +class AlertaModel { + final String id; + final TipoAlerta tipo; + final double distanciaMetros; + final DateTime fecha; + final String direccionCasa; + final bool leida; + + const AlertaModel({ + required this.id, + required this.tipo, + required this.distanciaMetros, + required this.fecha, + required this.direccionCasa, + this.leida = false, + }); + + String get distanciaTexto { + if (distanciaMetros < 1000) { + return '${distanciaMetros.toStringAsFixed(0)} m'; + } + return '${(distanciaMetros / 1000).toStringAsFixed(1)} km'; + } + + String get tiempoEstimadoTexto { + // ~5 km/h velocidad promedio del camión + final segundos = (distanciaMetros / (5000 / 3600)).round(); + if (segundos < 60) return 'Menos de 1 min'; + final minutos = (segundos / 60).ceil(); + return '~$minutos min'; + } + + String get fechaFormateada { + final ahora = DateTime.now(); + final hoy = DateTime(ahora.year, ahora.month, ahora.day); + final fechaDia = DateTime(fecha.year, fecha.month, fecha.day); + + if (fechaDia == hoy) { + return 'Hoy, ${_formatHora(fecha)}'; + } + final ayer = hoy.subtract(const Duration(days: 1)); + if (fechaDia == ayer) return 'Ayer, ${_formatHora(fecha)}'; + + const dias = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + const meses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; + return '${dias[fecha.weekday - 1]} ${fecha.day} ${meses[fecha.month - 1]}, ${_formatHora(fecha)}'; + } + + String get etiquetaFecha { + final ahora = DateTime.now(); + final hoy = DateTime(ahora.year, ahora.month, ahora.day); + final fechaDia = DateTime(fecha.year, fecha.month, fecha.day); + if (fechaDia == hoy) return 'Hoy'; + const dias = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + return dias[fecha.weekday - 1]; + } + + String _formatHora(DateTime dt) { + final h = dt.hour > 12 ? dt.hour - 12 : dt.hour == 0 ? 12 : dt.hour; + final m = dt.minute.toString().padLeft(2, '0'); + final ampm = dt.hour >= 12 ? 'p.m.' : 'a.m.'; + return '$h:$m $ampm'; + } +} diff --git a/recolecta_app/views/lib/screens/alerts_screen.dart b/recolecta_app/views/lib/screens/alerts_screen.dart new file mode 100644 index 0000000..7dd0df5 --- /dev/null +++ b/recolecta_app/views/lib/screens/alerts_screen.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class AlertsScreen extends StatefulWidget { + const AlertsScreen({super.key}); + + @override + State createState() => _AlertsScreenState(); +} + +class _AlertsScreenState extends State { + // Alerta activa de ejemplo + final AlertaModel? _alertaActiva = AlertaModel( + id: 'alerta-001', + tipo: TipoAlerta.cercana, + distanciaMetros: 180, + fecha: DateTime.now(), + direccionCasa: 'Av. Insurgentes 245', + leida: false, + ); + + // Historial de ejemplo + final List _historial = [ + AlertaModel( + id: 'h-001', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(hours: 1)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-002', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 2, hours: 2)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-003', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 4, hours: 1, minutes: 30)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + AlertaModel( + id: 'h-004', + tipo: TipoAlerta.cercana, + distanciaMetros: 200, + fecha: DateTime.now().subtract(const Duration(days: 7, hours: 3)), + direccionCasa: 'Av. Insurgentes 245', + leida: true, + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Alertas'), + actions: [ + TextButton( + onPressed: () {}, + child: const Text('Limpiar', + style: TextStyle(color: Colors.white, fontSize: 13)), + ), + ], + ), + body: RefreshIndicator( + color: AppTheme.primary, + onRefresh: () async { + await Future.delayed(const Duration(milliseconds: 800)); + }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Alerta activa ─────────────────────────────────────────── + if (_alertaActiva != null) ...[ + _AlertaActivaCard(alerta: _alertaActiva!), + const SizedBox(height: 20), + ], + + // ── Historial ──────────────────────────────────────────────── + if (_historial.isEmpty) + _EmptyState() + else ...[ + w.SectionTitle(title: 'Historial de alertas'), + ..._historial.map((a) => _AlertaHistorialItem(alerta: a)), + ], + ], + ), + ), + ); + } +} + +// ── Tarjeta de alerta activa ────────────────────────────────────────────────── +class _AlertaActivaCard extends StatelessWidget { + final AlertaModel alerta; + const _AlertaActivaCard({required this.alerta}); + + @override + Widget build(BuildContext context) { + final progreso = (1 - (alerta.distanciaMetros / 400)).clamp(0.0, 1.0); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.primaryMid), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.notifications_active, + color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('¡El camión está cerca!', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark)), + Text(alerta.fechaFormateada, + style: const TextStyle( + fontSize: 12, color: AppTheme.primary)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(20), + ), + child: const Text('Ahora', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white)), + ), + ], + ), + + const SizedBox(height: 16), + + // Distancia + Text( + 'El camión se encuentra a', + style: const TextStyle( + fontSize: 13, color: AppTheme.primaryDark), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + alerta.distanciaTexto, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppTheme.primary, + height: 1.1), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + 'de tu casa en ${alerta.direccionCasa}', + style: const TextStyle( + fontSize: 13, color: AppTheme.primaryDark), + ), + ), + ], + ), + + const SizedBox(height: 14), + + // Tiempo estimado + Row( + children: [ + const Text('Llegada estimada:', + style: TextStyle( + fontSize: 12, color: AppTheme.primaryDark)), + const SizedBox(width: 6), + Text( + alerta.tiempoEstimadoTexto, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primary), + ), + ], + ), + + const SizedBox(height: 8), + + // Barra de progreso + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progreso, + backgroundColor: AppTheme.primaryMid.withOpacity(0.4), + valueColor: const AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 7, + ), + ), + + const SizedBox(height: 4), + + Row( + children: const [ + Text('Lejos', + style: TextStyle(fontSize: 10, color: AppTheme.primary)), + Spacer(), + Text('Tu casa', + style: TextStyle(fontSize: 10, color: AppTheme.primary)), + ], + ), + + const SizedBox(height: 14), + + // Botón ver en mapa + GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.map_outlined, color: Colors.white, size: 16), + SizedBox(width: 6), + Text('Ver en el mapa', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white)), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Ítem de historial ───────────────────────────────────────────────────────── +class _AlertaHistorialItem extends StatelessWidget { + final AlertaModel alerta; + const _AlertaHistorialItem({required this.alerta}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.background, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.notifications_outlined, + color: AppTheme.textSecondary, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Camión a ${alerta.distanciaTexto}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary), + ), + const SizedBox(height: 2), + Text(alerta.fechaFormateada, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + ], + ), + ), + _EtiquetaDia(texto: alerta.etiquetaFecha), + ], + ), + ); + } +} + +// ── Etiqueta de día ─────────────────────────────────────────────────────────── +class _EtiquetaDia extends StatelessWidget { + final String texto; + const _EtiquetaDia({required this.texto}); + + @override + Widget build(BuildContext context) { + final esHoy = texto == 'Hoy'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: esHoy ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + texto, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: esHoy ? AppTheme.primaryDark : AppTheme.textSecondary, + ), + ), + ); + } +} + +// ── Estado vacío ────────────────────────────────────────────────────────────── +class _EmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 60), + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + ), + child: const Icon(Icons.notifications_outlined, + color: AppTheme.primary, size: 34), + ), + const SizedBox(height: 16), + const Text('Sin alertas por ahora', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 6), + const Text( + 'Te notificaremos cuando el camión\nesté cerca de tu casa.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary, height: 1.5), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/house_screen.dart b/recolecta_app/views/lib/screens/house_screen.dart new file mode 100644 index 0000000..8410fc4 --- /dev/null +++ b/recolecta_app/views/lib/screens/house_screen.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class MyHouseScreen extends StatefulWidget { + const MyHouseScreen({super.key}); + + @override + State createState() => _MyHouseScreenState(); +} + +class _MyHouseScreenState extends State { + HouseModel _casa = const HouseModel( + id: 'casa-01', + calle: 'Av. Insurgentes 245', + colonia: 'Centro', + codigoPostal: '38000', + latitud: 20.5226, + longitud: -100.8191, + radioAlertaMetros: 200, + alertaCercana: true, + alertaMedia: false, + recordatorioDiario: true, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Mi casa'), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => _mostrarEditarDireccion(context), + tooltip: 'Editar dirección', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Tarjeta de la casa ────────────────────────────────────── + _CasaCard(casa: _casa), + + const SizedBox(height: 16), + + // ── Configuración de radio ────────────────────────────────── + w.SectionTitle(title: 'Radio de alerta'), + _RadioAlertaCard( + radioActual: _casa.radioAlertaMetros, + onChanged: (v) => setState(() { + _casa = _casa.copyWith(radioAlertaMetros: v); + }), + ), + + const SizedBox(height: 16), + + // ── Notificaciones ────────────────────────────────────────── + w.SectionTitle(title: 'Notificaciones'), + _NotificacionesCard( + casa: _casa, + onAlertaCercanaChanged: (v) => + setState(() => _casa = _casa.copyWith(alertaCercana: v)), + onAlertaMediaChanged: (v) => + setState(() => _casa = _casa.copyWith(alertaMedia: v)), + onRecordatorioChanged: (v) => + setState(() => _casa = _casa.copyWith(recordatorioDiario: v)), + ), + + const SizedBox(height: 16), + + // ── Horario estimado ──────────────────────────────────────── + w.SectionTitle(title: 'Horario del camión'), + _HorarioCard(), + + const SizedBox(height: 16), + + // ── Agregar otra casa ─────────────────────────────────────── + GestureDetector( + onTap: () => _mostrarAgregarCasa(context), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all( + color: AppTheme.primaryMid, + width: 1, + style: BorderStyle.solid), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.add_home_outlined, + color: AppTheme.primary, size: 20), + SizedBox(width: 8), + Text('Agregar otra dirección', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.primary)), + ], + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ); + } + + void _mostrarEditarDireccion(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppTheme.radiusXl)), + ), + builder: (_) => _EditarDireccionSheet(casa: _casa), + ); + } + + void _mostrarAgregarCasa(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Funcionalidad próximamente disponible'), + behavior: SnackBarBehavior.floating, + backgroundColor: AppTheme.primary, + ), + ); + } +} + +// ── Tarjeta principal de la casa ────────────────────────────────────────────── +class _CasaCard extends StatelessWidget { + final HouseModel casa; + const _CasaCard({required this.casa}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.primaryMid, width: 0.8), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.home_outlined, + color: AppTheme.primary, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(casa.alias, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 2), + w.StatusBadge.green( + casa.activa ? 'Activa' : 'Inactiva'), + ], + ), + ), + IconButton( + icon: const Icon(Icons.more_vert, + color: AppTheme.textSecondary, size: 20), + onPressed: () {}, + ), + ], + ), + + const SizedBox(height: 14), + const Divider(color: AppTheme.borderLight), + const SizedBox(height: 10), + + // Detalles + _DetailRow( + icon: Icons.location_on_outlined, + text: casa.direccionCompleta, + ), + const SizedBox(height: 8), + _DetailRow( + icon: Icons.my_location_outlined, + text: + '${casa.latitud.toStringAsFixed(4)}, ${casa.longitud.toStringAsFixed(4)}', + ), + const SizedBox(height: 8), + _DetailRow( + icon: Icons.radar_outlined, + text: 'Alerta a ${casa.radioAlertaMetros} m de distancia', + ), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final IconData icon; + final String text; + const _DetailRow({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 15, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text(text, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary, height: 1.4)), + ), + ], + ); + } +} + +// ── Radio de alerta ─────────────────────────────────────────────────────────── +class _RadioAlertaCard extends StatelessWidget { + final int radioActual; + final ValueChanged onChanged; + const _RadioAlertaCard({required this.radioActual, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + children: [200, 400, 600].map((dist) { + final selected = dist == radioActual; + return GestureDetector( + onTap: () => onChanged(dist), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: selected ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: selected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: selected ? AppTheme.primary : AppTheme.border, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '$dist metros', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selected + ? AppTheme.primaryDark + : AppTheme.textPrimary, + ), + ), + ), + if (selected) + Text( + dist == 200 + ? '~2-3 min' + : dist == 400 + ? '~4-5 min' + : '~6-8 min', + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Notificaciones ──────────────────────────────────────────────────────────── +class _NotificacionesCard extends StatelessWidget { + final HouseModel casa; + final ValueChanged onAlertaCercanaChanged; + final ValueChanged onAlertaMediaChanged; + final ValueChanged onRecordatorioChanged; + + const _NotificacionesCard({ + required this.casa, + required this.onAlertaCercanaChanged, + required this.onAlertaMediaChanged, + required this.onRecordatorioChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + children: [ + w.LabeledSwitch( + label: 'Alerta cuando el camión esté cerca', + value: casa.alertaCercana, + onChanged: onAlertaCercanaChanged, + ), + const Divider(height: 1, color: AppTheme.borderLight), + w.LabeledSwitch( + label: 'Alerta a distancia media', + value: casa.alertaMedia, + onChanged: onAlertaMediaChanged, + ), + const Divider(height: 1, color: AppTheme.borderLight), + w.LabeledSwitch( + label: 'Recordatorio diario del horario', + value: casa.recordatorioDiario, + onChanged: onRecordatorioChanged, + ), + ], + ), + ); + } +} + +// ── Horario del camión ──────────────────────────────────────────────────────── +class _HorarioCard extends StatelessWidget { + final List<_HorarioDia> _dias = const [ + _HorarioDia(dia: 'Lunes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Martes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false), + _HorarioDia(dia: 'Jueves', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Viernes', hora: '8:00 – 10:00 a.m.', activo: true), + _HorarioDia(dia: 'Sábado', hora: '9:00 – 11:00 a.m.', activo: true), + _HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false), + ]; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + children: _dias.map((d) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Text(d.dia, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: d.activo + ? AppTheme.textPrimary + : AppTheme.textSecondary)), + const Spacer(), + Text(d.hora, + style: TextStyle( + fontSize: 13, + color: d.activo + ? AppTheme.primary + : AppTheme.textSecondary)), + ], + ), + ); + }).toList(), + ), + ); + } +} + +class _HorarioDia { + final String dia; + final String hora; + final bool activo; + const _HorarioDia( + {required this.dia, required this.hora, required this.activo}); +} + +// ── Sheet de editar dirección ───────────────────────────────────────────────── +class _EditarDireccionSheet extends StatelessWidget { + final HouseModel casa; + const _EditarDireccionSheet({required this.casa}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, right: 24, top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 20), + + const Text('Editar dirección', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + const SizedBox(height: 20), + + w.FormField( + label: 'Calle y número', initialValue: casa.calle), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: w.FormField( + label: 'Colonia', initialValue: casa.colonia), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: w.FormField( + label: 'C.P.', initialValue: casa.codigoPostal), + ), + ], + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Guardar cambios'), + ), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/login_screen.dart b/recolecta_app/views/lib/screens/login_screen.dart new file mode 100644 index 0000000..cc4366c --- /dev/null +++ b/recolecta_app/views/lib/screens/login_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../widgets/widgets.dart' as w; +import 'main_shell.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscurePass = true; + bool _loading = false; + + @override + void dispose() { + _emailCtrl.dispose(); + _passCtrl.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + await Future.delayed(const Duration(seconds: 1)); // Simular petición + if (!mounted) return; + setState(() => _loading = false); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainShell()), + (_) => false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: const Text( + 'Iniciar sesión', + style: TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + + // ── Encabezado ───────────────────────────────────────── + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: + BorderRadius.circular(AppTheme.radiusMd), + ), + child: const Icon(Icons.delete_outline_rounded, + color: AppTheme.primary, size: 26), + ), + const SizedBox(width: 14), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('RutaVerde', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + Text('Bienvenido de nuevo', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary)), + ], + ), + ], + ), + + const SizedBox(height: 32), + + // ── Formulario ───────────────────────────────────────── + w.FormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + w.FormField( + label: 'Contraseña', + hint: '••••••••', + controller: _passCtrl, + obscureText: _obscurePass, + suffix: IconButton( + icon: Icon( + _obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, + color: AppTheme.textSecondary, + ), + onPressed: () => + setState(() => _obscurePass = !_obscurePass), + ), + ), + + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primary), + child: const Text('¿Olvidaste tu contraseña?', + style: TextStyle(fontSize: 13)), + ), + ), + + const SizedBox(height: 24), + + // ── Botón ingresar ────────────────────────────────────── + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _loading ? null : _login, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Ingresar'), + ), + ), + + const SizedBox(height: 28), + + // ── Divisor ───────────────────────────────────────────── + Row( + children: [ + const Expanded(child: Divider(color: AppTheme.border)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('o', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + const Expanded(child: Divider(color: AppTheme.border)), + ], + ), + + const SizedBox(height: 20), + + // ── Continuar con Google ──────────────────────────────── + _SocialButton( + icon: Icons.g_mobiledata_rounded, + label: 'Continuar con Google', + onTap: () {}, + ), + + const SizedBox(height: 36), + + // ── Crear cuenta ──────────────────────────────────────── + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('¿No tienes cuenta? ', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text('Regístrate', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary)), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SocialButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _SocialButton( + {required this.icon, required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 22, color: AppTheme.textPrimary), + const SizedBox(width: 10), + Text(label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary)), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/main_shell.dart b/recolecta_app/views/lib/screens/main_shell.dart new file mode 100644 index 0000000..cb9eb8d --- /dev/null +++ b/recolecta_app/views/lib/screens/main_shell.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import '../widgets/widgets.dart' as w; +import 'map_screen.dart'; +import 'alerts_screen.dart'; +import 'house_screen.dart'; +import 'profile_screen.dart'; + +class MainShell extends StatefulWidget { + const MainShell({super.key}); + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + int _currentIndex = 0; + + final List _screens = const [ + MapScreen(), + AlertsScreen(), + MyHouseScreen(), + ProfileScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: w.AppBottomNav( + currentIndex: _currentIndex, + onTap: (i) => setState(() => _currentIndex = i), + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/map_screen.dart b/recolecta_app/views/lib/screens/map_screen.dart new file mode 100644 index 0000000..16537f6 --- /dev/null +++ b/recolecta_app/views/lib/screens/map_screen.dart @@ -0,0 +1,383 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; + +class MapScreen extends StatefulWidget { + const MapScreen({super.key}); + + @override + State createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + final Completer _mapController = Completer(); + + // Coordenadas de ejemplo — Celaya, Gto. + static const LatLng _casaPos = LatLng(20.5226, -100.8191); + static const LatLng _camionPos = LatLng(20.5255, -100.8220); + static const CameraPosition _camaraInicial = CameraPosition( + target: LatLng(20.5240, -100.8205), + zoom: 15.5, + ); + + // Datos de ejemplo del camión + final TruckLocation _camion = TruckLocation( + id: 'truck-01', + ruta: 'Ruta Norte', + latitud: _camionPos.latitude, + longitud: _camionPos.longitude, + ultimaActualizacion: DateTime.now().subtract(const Duration(seconds: 28)), + enServicio: true, + ); + + final HouseModel _casa = const HouseModel( + id: 'casa-01', + calle: 'Av. Insurgentes 245', + colonia: 'Centro', + codigoPostal: '38000', + latitud: _casaPos.latitude, + longitud: _casaPos.longitude, + radioAlertaMetros: 200, + ); + + Set _markers = {}; + Set _circles = {}; + bool _mapLoaded = false; + Timer? _refreshTimer; + + // Distancia simulada (metros) + double get _distanciaMetros => 380; + int get _minutosEstimados => 8; + + @override + void initState() { + super.initState(); + _buildMapElements(); + // Simular actualización de posición cada 30s + _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + void _buildMapElements() { + _markers = { + Marker( + markerId: const MarkerId('camion'), + position: LatLng(_camion.latitud, _camion.longitud), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), + infoWindow: InfoWindow( + title: 'Camión · ${_camion.ruta}', + snippet: _camion.tiempoActualizacion, + ), + ), + Marker( + markerId: const MarkerId('casa'), + position: LatLng(_casa.latitud, _casa.longitud), + icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue), + infoWindow: InfoWindow(title: _casa.alias, snippet: _casa.calle), + ), + }; + + _circles = { + Circle( + circleId: const CircleId('radio-alerta'), + center: LatLng(_casa.latitud, _casa.longitud), + radius: _casa.radioAlertaMetros.toDouble(), + fillColor: AppTheme.blue.withOpacity(0.08), + strokeColor: AppTheme.blue.withOpacity(0.4), + strokeWidth: 1, + ), + }; + } + + Future _centrarMapa() async { + final controller = await _mapController.future; + await controller.animateCamera( + CameraUpdate.newCameraPosition(_camaraInicial), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + title: const Text('Rastreo en vivo'), + actions: [ + IconButton( + icon: const Icon(Icons.my_location), + onPressed: _centrarMapa, + tooltip: 'Centrar mapa', + ), + ], + ), + body: Column( + children: [ + // ── Mapa ───────────────────────────────────────────────────── + Expanded( + flex: 5, + child: Stack( + children: [ + GoogleMap( + initialCameraPosition: _camaraInicial, + markers: _markers, + circles: _circles, + myLocationButtonEnabled: false, + zoomControlsEnabled: false, + mapType: MapType.normal, + onMapCreated: (c) { + _mapController.complete(c); + setState(() => _mapLoaded = true); + }, + ), + + // Indicador "En vivo" + Positioned( + top: 14, + right: 14, + child: _LiveBadge(activo: _camion.enServicio), + ), + + // Actualización + Positioned( + top: 14, + left: 14, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.refresh, + size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + _camion.tiempoActualizacion, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary), + ), + ], + ), + ), + ), + ], + ), + ), + + // ── Panel inferior ──────────────────────────────────────────── + Expanded( + flex: 3, + child: Container( + decoration: BoxDecoration( + color: AppTheme.background, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppTheme.radiusXl)), + boxShadow: AppTheme.cardShadow, + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Camión + w.InfoRow( + icon: Icons.delete_outline_rounded, + label: '${_camion.ruta} · ${_camion.tiempoActualizacion}', + value: 'Camión a ${_distanciaMetros.toStringAsFixed(0)} m', + trailing: w.StatusBadge.amber('~$_minutosEstimados min'), + ), + const SizedBox(height: 10), + // Casa + w.InfoRow( + icon: Icons.home_outlined, + label: _casa.direccionCompleta, + value: _casa.alias, + trailing: w.StatusBadge.green('Activa'), + ), + const SizedBox(height: 12), + // Barra de progreso de llegada + _ArrivalBar( + distanciaActual: _distanciaMetros, + distanciaTotal: 1000, + minutos: _minutosEstimados, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Badge "En vivo" ─────────────────────────────────────────────────────────── +class _LiveBadge extends StatefulWidget { + final bool activo; + const _LiveBadge({required this.activo}); + + @override + State<_LiveBadge> createState() => _LiveBadgeState(); +} + +class _LiveBadgeState extends State<_LiveBadge> + with SingleTickerProviderStateMixin { + late AnimationController _anim; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: AppTheme.softShadow, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _anim, + builder: (_, __) => Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.activo + ? AppTheme.primary.withOpacity(0.5 + _anim.value * 0.5) + : AppTheme.textSecondary, + ), + ), + ), + const SizedBox(width: 5), + Text( + widget.activo ? 'En vivo' : 'Sin servicio', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: widget.activo ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ], + ), + ); + } +} + +// ── Barra de llegada estimada ───────────────────────────────────────────────── +class _ArrivalBar extends StatelessWidget { + final double distanciaActual; + final double distanciaTotal; + final int minutos; + + const _ArrivalBar({ + required this.distanciaActual, + required this.distanciaTotal, + required this.minutos, + }); + + @override + Widget build(BuildContext context) { + final progreso = + ((distanciaTotal - distanciaActual) / distanciaTotal).clamp(0.0, 1.0); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.primaryMid, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('Llegada estimada', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.primaryDark)), + const Spacer(), + Text('~$minutos min', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.primary)), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progreso, + backgroundColor: AppTheme.primaryMid.withOpacity(0.4), + valueColor: + const AlwaysStoppedAnimation(AppTheme.primary), + minHeight: 6, + ), + ), + const SizedBox(height: 4), + Row( + children: const [ + Text('Ahora', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + Spacer(), + Text('Tu casa', + style: TextStyle( + fontSize: 10, color: AppTheme.primaryDark)), + ], + ), + ], + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/profile_screen.dart b/recolecta_app/views/lib/screens/profile_screen.dart new file mode 100644 index 0000000..f4b1d05 --- /dev/null +++ b/recolecta_app/views/lib/screens/profile_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../models/models.dart'; +import '../widgets/widgets.dart' as w; +import 'splash_screen.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + final UserModel _usuario = const UserModel( + id: 'user-01', + nombre: 'Carlos', + apellido: 'Martínez', + email: 'carlos@ejemplo.com', + telefono: '+52 461 123 4567', + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar(title: const Text('Mi perfil')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Avatar y datos ───────────────────────────────────────── + _ProfileHeader(usuario: _usuario), + + const SizedBox(height: 20), + + // ── Mi cuenta ────────────────────────────────────────────── + w.SectionTitle(title: 'Mi cuenta'), + w.MenuTile( + icon: Icons.person_outline, + title: 'Editar perfil', + subtitle: '${_usuario.nombre} ${_usuario.apellido}', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.lock_outline, + title: 'Cambiar contraseña', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.phone_outlined, + title: 'Teléfono', + subtitle: _usuario.telefono, + onTap: () {}, + ), + + const SizedBox(height: 16), + + // ── Configuración ────────────────────────────────────────── + w.SectionTitle(title: 'Configuración'), + w.MenuTile( + icon: Icons.calendar_month_outlined, + title: 'Horario del camión', + subtitle: 'Ruta Norte · Celaya', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.language_outlined, + title: 'Idioma', + subtitle: 'Español', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.dark_mode_outlined, + title: 'Tema', + subtitle: 'Claro', + onTap: () {}, + ), + + const SizedBox(height: 16), + + // ── Soporte ──────────────────────────────────────────────── + w.SectionTitle(title: 'Soporte'), + w.MenuTile( + icon: Icons.help_outline, + title: 'Ayuda y preguntas frecuentes', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.bug_report_outlined, + title: 'Reportar un problema', + onTap: () {}, + ), + w.MenuTile( + icon: Icons.info_outline, + title: 'Acerca de la app', + subtitle: 'Versión 1.0.0', + onTap: () {}, + ), + + const SizedBox(height: 16), + + // ── Cerrar sesión ────────────────────────────────────────── + w.MenuTile( + icon: Icons.logout_rounded, + title: 'Cerrar sesión', + iconColor: AppTheme.danger, + titleColor: AppTheme.danger, + trailing: const SizedBox.shrink(), + onTap: () => _confirmarCerrarSesion(context), + ), + + const SizedBox(height: 32), + + Center( + child: Text( + 'RutaVerde v1.0.0\nServicio de Limpia · Celaya, Gto.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textHint, + height: 1.6), + ), + ), + + const SizedBox(height: 24), + ], + ), + ); + } + + void _confirmarCerrarSesion(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusLg)), + title: const Text('Cerrar sesión', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary)), + content: const Text( + '¿Estás seguro de que deseas cerrar sesión?', + style: TextStyle(fontSize: 14, color: AppTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: AppTheme.textSecondary), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const SplashScreen()), + (_) => false, + ); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.danger), + child: const Text('Cerrar sesión', + style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +// ── Encabezado de perfil ────────────────────────────────────────────────────── +class _ProfileHeader extends StatelessWidget { + final UserModel usuario; + const _ProfileHeader({required this.usuario}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + // Avatar con iniciales + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + shape: BoxShape.circle, + border: Border.all(color: AppTheme.primaryMid, width: 1.5), + ), + child: Center( + child: Text( + usuario.iniciales, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + usuario.nombreCompleto, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary), + ), + const SizedBox(height: 2), + Text( + usuario.email, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 6), + w.StatusBadge.green('Cuenta activa'), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined, + color: AppTheme.primary, size: 20), + onPressed: () {}, + ), + ], + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/register_screen.dart b/recolecta_app/views/lib/screens/register_screen.dart new file mode 100644 index 0000000..172f62d --- /dev/null +++ b/recolecta_app/views/lib/screens/register_screen.dart @@ -0,0 +1,541 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../widgets/widgets.dart' as w; +import 'main_shell.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _pageController = PageController(); + int _currentPage = 0; + bool _loading = false; + + // Paso 1 + final _nombreCtrl = TextEditingController(); + final _apellidoCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _telefonoCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + bool _obscurePass = true; + + // Paso 2 + final _calleCtrl = TextEditingController(); + final _coloniaCtrl = TextEditingController(); + final _cpCtrl = TextEditingController(); + int _radioAlerta = 200; + + @override + void dispose() { + _pageController.dispose(); + _nombreCtrl.dispose(); _apellidoCtrl.dispose(); + _emailCtrl.dispose(); _telefonoCtrl.dispose(); _passCtrl.dispose(); + _calleCtrl.dispose(); _coloniaCtrl.dispose(); _cpCtrl.dispose(); + super.dispose(); + } + + void _nextPage() { + _pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + setState(() => _currentPage = 1); + } + + Future _register() async { + setState(() => _loading = true); + await Future.delayed(const Duration(seconds: 1)); + if (!mounted) return; + setState(() => _loading = false); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MainShell()), + (_) => false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.textPrimary), + title: Text( + _currentPage == 0 ? 'Crear cuenta' : 'Mi dirección', + style: const TextStyle(color: AppTheme.textPrimary, fontSize: 16), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(4), + child: _StepIndicator(current: _currentPage, total: 2), + ), + ), + body: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + _Step1( + nombreCtrl: _nombreCtrl, + apellidoCtrl: _apellidoCtrl, + emailCtrl: _emailCtrl, + telefonoCtrl: _telefonoCtrl, + passCtrl: _passCtrl, + obscurePass: _obscurePass, + onTogglePass: () => setState(() => _obscurePass = !_obscurePass), + onNext: _nextPage, + ), + _Step2( + calleCtrl: _calleCtrl, + coloniaCtrl: _coloniaCtrl, + cpCtrl: _cpCtrl, + radioAlerta: _radioAlerta, + onRadioChanged: (v) => setState(() => _radioAlerta = v), + onRegister: _register, + loading: _loading, + ), + ], + ), + ); + } +} + +// ── Indicador de pasos ──────────────────────────────────────────────────────── +class _StepIndicator extends StatelessWidget { + final int current; + final int total; + + const _StepIndicator({required this.current, required this.total}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Row( + children: List.generate(total, (i) { + final active = i <= current; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < total - 1 ? 6 : 0), + height: 4, + decoration: BoxDecoration( + color: active ? AppTheme.primary : AppTheme.border, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }), + ), + ); + } +} + +// ── Paso 1: Datos personales ────────────────────────────────────────────────── +class _Step1 extends StatelessWidget { + final TextEditingController nombreCtrl, apellidoCtrl, emailCtrl, + telefonoCtrl, passCtrl; + final bool obscurePass; + final VoidCallback onTogglePass; + final VoidCallback onNext; + + const _Step1({ + required this.nombreCtrl, required this.apellidoCtrl, + required this.emailCtrl, required this.telefonoCtrl, + required this.passCtrl, required this.obscurePass, + required this.onTogglePass, required this.onNext, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + + // ── Sección personal ────────────────────────────────────────── + _FormCard( + icon: Icons.person_outline, + title: 'Información personal', + child: Column( + children: [ + Row( + children: [ + Expanded( + child: w.FormField( + label: 'Nombre', + hint: 'Carlos', + controller: nombreCtrl, + ), + ), + const SizedBox(width: 12), + Expanded( + child: w.FormField( + label: 'Apellido', + hint: 'Martínez', + controller: apellidoCtrl, + ), + ), + ], + ), + const SizedBox(height: 14), + w.FormField( + label: 'Correo electrónico', + hint: 'tu@correo.com', + controller: emailCtrl, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 14), + w.FormField( + label: 'Teléfono', + hint: '+52 461 123 4567', + controller: telefonoCtrl, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 14), + w.FormField( + label: 'Contraseña', + hint: '••••••••', + controller: passCtrl, + obscureText: obscurePass, + suffix: IconButton( + icon: Icon( + obscurePass + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 18, color: AppTheme.textSecondary, + ), + onPressed: onTogglePass, + ), + ), + ], + ), + ), + + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: onNext, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text('Siguiente'), + SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + + const SizedBox(height: 20), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('¿Ya tienes cuenta? ', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text('Inicia sesión', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primary)), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Paso 2: Dirección ───────────────────────────────────────────────────────── +class _Step2 extends StatelessWidget { + final TextEditingController calleCtrl, coloniaCtrl, cpCtrl; + final int radioAlerta; + final ValueChanged onRadioChanged; + final VoidCallback onRegister; + final bool loading; + + const _Step2({ + required this.calleCtrl, required this.coloniaCtrl, required this.cpCtrl, + required this.radioAlerta, required this.onRadioChanged, + required this.onRegister, required this.loading, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + + _FormCard( + icon: Icons.home_outlined, + title: 'Dirección de tu casa', + child: Column( + children: [ + w.FormField( + label: 'Calle y número', + hint: 'Av. Insurgentes 245', + controller: calleCtrl, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: w.FormField( + label: 'Colonia', + hint: 'Centro', + controller: coloniaCtrl, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: w.FormField( + label: 'C.P.', + hint: '38000', + controller: cpCtrl, + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 14), + + // Usar ubicación actual + GestureDetector( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 11, horizontal: 14), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: + BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: AppTheme.primaryMid, width: 0.5), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.my_location, + color: AppTheme.primary, size: 18), + SizedBox(width: 8), + Text('Usar mi ubicación actual', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.primaryDark)), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + _FormCard( + icon: Icons.notifications_outlined, + title: 'Distancia de alerta', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Te avisamos cuando el camión esté a esta distancia de tu casa.', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary, + height: 1.4), + ), + const SizedBox(height: 14), + ...([200, 400, 600]).map((dist) => _RadioOption( + value: dist, + groupValue: radioAlerta, + label: '$dist metros', + sublabel: dist == 200 + ? 'Alerta muy temprana (~2-3 min)' + : dist == 400 + ? 'Alerta temprana (~4-5 min)' + : 'Alerta anticipada (~6-8 min)', + onChanged: onRadioChanged, + )), + ], + ), + ), + + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: loading ? null : onRegister, + child: loading + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, size: 18), + SizedBox(width: 8), + Text('Registrarme'), + ], + ), + ), + ), + + const SizedBox(height: 16), + Center( + child: Text( + 'Al registrarte aceptas los Términos de Servicio\ny la Política de Privacidad.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, color: AppTheme.textSecondary, height: 1.5), + ), + ), + ], + ), + ); + } +} + +// ── Tarjeta de formulario ───────────────────────────────────────────────────── +class _FormCard extends StatelessWidget { + final IconData icon; + final String title; + final Widget child; + + const _FormCard( + {required this.icon, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: AppTheme.primary, size: 18), + const SizedBox(width: 8), + Text(title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + +// ── Opción radio ────────────────────────────────────────────────────────────── +class _RadioOption extends StatelessWidget { + final int value; + final int groupValue; + final String label; + final String sublabel; + final ValueChanged onChanged; + + const _RadioOption({ + required this.value, required this.groupValue, + required this.label, required this.sublabel, required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + return GestureDetector( + onTap: () => onChanged(value), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: selected ? AppTheme.primaryLight : AppTheme.background, + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: selected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? AppTheme.primary : AppTheme.border, + width: 2, + ), + ), + child: selected + ? Center( + child: Container( + width: 8, height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppTheme.primary, + ), + ), + ) + : null, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selected + ? AppTheme.primaryDark + : AppTheme.textPrimary)), + Text(sublabel, + style: TextStyle( + fontSize: 11, + color: selected + ? AppTheme.primary + : AppTheme.textSecondary)), + ], + ), + ], + ), + ), + ); + } +} diff --git a/recolecta_app/views/lib/screens/splash_screen.dart b/recolecta_app/views/lib/screens/splash_screen.dart new file mode 100644 index 0000000..1e7a0dd --- /dev/null +++ b/recolecta_app/views/lib/screens/splash_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import 'login_screen.dart'; +import 'register_screen.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeIn; + late Animation _slideUp; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _fadeIn = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + _slideUp = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppTheme.primary, AppTheme.primaryDark], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + children: [ + const Spacer(flex: 2), + + // ── Ícono de la app ───────────────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: + BorderRadius.circular(AppTheme.radiusXl), + ), + child: const Icon( + Icons.delete_outline_rounded, + size: 46, + color: Colors.white, + ), + ), + ), + + const SizedBox(height: 24), + + // ── Nombre y descripción ──────────────────────────────── + SlideTransition( + position: _slideUp, + child: FadeTransition( + opacity: _fadeIn, + child: Column( + children: [ + const Text( + 'RutaVerde', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 10), + Text( + 'Sigue en tiempo real el camión de basura\ny recibe alertas cuando esté cerca.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: Colors.white.withOpacity(0.82), + height: 1.5, + ), + ), + ], + ), + ), + ), + + const Spacer(flex: 3), + + // ── Características rápidas ───────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _FeatureChip( + icon: Icons.location_on_outlined, + label: 'Rastreo en vivo', + ), + _FeatureChip( + icon: Icons.notifications_outlined, + label: 'Alertas', + ), + _FeatureChip( + icon: Icons.home_outlined, + label: 'Tu dirección', + ), + ], + ), + ), + + const SizedBox(height: 40), + + // ── Botones ───────────────────────────────────────────── + FadeTransition( + opacity: _fadeIn, + child: Column( + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppTheme.primaryDark, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(AppTheme.radiusMd), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const RegisterScreen(), + ), + ); + }, + child: const Text('Crear cuenta'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen(), + ), + ); + }, + child: const Text('Ya tengo cuenta'), + ), + ], + ), + ), + + const SizedBox(height: 24), + + Text( + 'Servicio de Limpia · Celaya, Gto.', + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.45), + ), + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } +} + +class _FeatureChip extends StatelessWidget { + final IconData icon; + final String label; + + const _FeatureChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.12), + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white, size: 22), + const SizedBox(height: 5), + Text( + label, + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/recolecta_app/views/lib/theme/app_theme.dart b/recolecta_app/views/lib/theme/app_theme.dart new file mode 100644 index 0000000..567d82d --- /dev/null +++ b/recolecta_app/views/lib/theme/app_theme.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // ── Colores principales ────────────────────────────────────────────────── + static const Color primary = Color(0xFF1D9E75); + static const Color primaryDark = Color(0xFF0F6E56); + static const Color primaryLight = Color(0xFFE1F5EE); + static const Color primaryMid = Color(0xFF9FE1CB); + + static const Color blue = Color(0xFF185FA5); + static const Color blueLight = Color(0xFFE6F1FB); + + static const Color amber = Color(0xFF854F0B); + static const Color amberLight = Color(0xFFFAEEDA); + + static const Color danger = Color(0xFFE24B4A); + static const Color dangerLight = Color(0xFFFCEBEB); + + static const Color textPrimary = Color(0xFF1A1A1A); + static const Color textSecondary = Color(0xFF6B7280); + static const Color textHint = Color(0xFFAAAAAA); + + static const Color surface = Color(0xFFFFFFFF); + static const Color background = Color(0xFFF5F7F5); + static const Color border = Color(0xFFE5E7EB); + static const Color borderLight = Color(0xFFF0F2F0); + + // ── Radios ─────────────────────────────────────────────────────────────── + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 24.0; + static const double radiusFull = 100.0; + + // ── Sombras ────────────────────────────────────────────────────────────── + static List get cardShadow => [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ]; + + static List get softShadow => [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ]; + + // ── ThemeData ──────────────────────────────────────────────────────────── + static ThemeData get lightTheme => ThemeData( + useMaterial3: true, + fontFamily: 'SF Pro Display', + colorScheme: ColorScheme.fromSeed( + seedColor: primary, + primary: primary, + secondary: primaryDark, + surface: surface, + background: background, + ), + scaffoldBackgroundColor: background, + appBarTheme: const AppBarTheme( + backgroundColor: primary, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + elevation: 0, + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54, width: 1.5), + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: primary, width: 1.5), + ), + labelStyle: const TextStyle(color: textSecondary, fontSize: 13), + hintStyle: const TextStyle(color: textHint, fontSize: 13), + ), + ); +} diff --git a/recolecta_app/views/lib/widgets/widgets.dart b/recolecta_app/views/lib/widgets/widgets.dart new file mode 100644 index 0000000..b4e2137 --- /dev/null +++ b/recolecta_app/views/lib/widgets/widgets.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +// ── Badge de estado ─────────────────────────────────────────────────────────── +class StatusBadge extends StatelessWidget { + final String label; + final Color backgroundColor; + final Color textColor; + + const StatusBadge({ + super.key, + required this.label, + this.backgroundColor = AppTheme.primaryLight, + this.textColor = AppTheme.primaryDark, + }); + + factory StatusBadge.green(String label) => StatusBadge( + label: label, + backgroundColor: AppTheme.primaryLight, + textColor: AppTheme.primaryDark, + ); + + factory StatusBadge.amber(String label) => StatusBadge( + label: label, + backgroundColor: AppTheme.amberLight, + textColor: AppTheme.amber, + ); + + factory StatusBadge.gray(String label) => StatusBadge( + label: label, + backgroundColor: const Color(0xFFF1EFE8), + textColor: const Color(0xFF5F5E5A), + ); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(AppTheme.radiusFull), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ); + } +} + +// ── Tarjeta base ────────────────────────────────────────────────────────────── +class AppCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final Color? borderColor; + final VoidCallback? onTap; + + const AppCard({ + super.key, + required this.child, + this.padding, + this.borderColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all( + color: borderColor ?? AppTheme.border, + width: 0.5, + ), + boxShadow: AppTheme.softShadow, + ), + child: child, + ), + ); + } +} + +// ── Fila de información con ícono ───────────────────────────────────────────── +class InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Widget? trailing; + + const InfoRow({ + super.key, + required this.icon, + required this.label, + required this.value, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppTheme.primary, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary)), + const SizedBox(height: 2), + Text(label, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +// ── Campo de formulario ─────────────────────────────────────────────────────── +class FormField extends StatelessWidget { + final String label; + final String? hint; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final String? initialValue; + final Widget? suffix; + final int? maxLines; + + const FormField({ + super.key, + required this.label, + this.hint, + this.controller, + this.obscureText = false, + this.keyboardType, + this.initialValue, + this.suffix, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary)), + const SizedBox(height: 6), + TextFormField( + controller: controller, + initialValue: initialValue, + obscureText: obscureText, + keyboardType: keyboardType, + maxLines: maxLines, + style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary), + decoration: InputDecoration( + hintText: hint, + suffixIcon: suffix, + ), + ), + ], + ); + } +} + +// ── Sección con título ──────────────────────────────────────────────────────── +class SectionTitle extends StatelessWidget { + final String title; + final Widget? action; + + const SectionTitle({super.key, required this.title, this.action}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Text( + title.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + letterSpacing: 0.8, + ), + ), + const Spacer(), + if (action != null) action!, + ], + ), + ); + } +} + +// ── Toggle con label ────────────────────────────────────────────────────────── +class LabeledSwitch extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const LabeledSwitch({ + super.key, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle( + fontSize: 14, color: AppTheme.textPrimary)), + ), + Switch.adaptive( + value: value, + onChanged: onChanged, + activeColor: AppTheme.primary, + ), + ], + ), + ); + } +} + +// ── Ítem de menú ────────────────────────────────────────────────────────────── +class MenuTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + final Color? iconColor; + final Color? titleColor; + final Widget? trailing; + + const MenuTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.onTap, + this.iconColor, + this.titleColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: Border.all(color: AppTheme.border, width: 0.5), + boxShadow: AppTheme.softShadow, + ), + child: Row( + children: [ + Icon(icon, + color: iconColor ?? AppTheme.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: titleColor ?? AppTheme.textPrimary)), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text(subtitle!, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + ], + ], + ), + ), + trailing ?? + const Icon(Icons.chevron_right, + color: AppTheme.textSecondary, size: 18), + ], + ), + ), + ); + } +} + +// ── Bottom Nav Bar ──────────────────────────────────────────────────────────── +class AppBottomNav extends StatelessWidget { + final int currentIndex; + final Function(int) onTap; + + const AppBottomNav({ + super.key, + required this.currentIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + currentIndex: currentIndex, + onTap: onTap, + type: BottomNavigationBarType.fixed, + backgroundColor: AppTheme.surface, + selectedItemColor: AppTheme.primary, + unselectedItemColor: AppTheme.textSecondary, + selectedFontSize: 11, + unselectedFontSize: 11, + elevation: 12, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.map_outlined), + activeIcon: Icon(Icons.map), + label: 'Mapa', + ), + BottomNavigationBarItem( + icon: Icon(Icons.notifications_outlined), + activeIcon: Icon(Icons.notifications), + label: 'Alertas', + ), + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + label: 'Mi casa', + ), + BottomNavigationBarItem( + icon: Icon(Icons.person_outline), + activeIcon: Icon(Icons.person), + label: 'Perfil', + ), + ], + ); + } +} diff --git a/recolecta_app/views/pubspec.lock b/recolecta_app/views/pubspec.lock new file mode 100644 index 0000000..3f73744 --- /dev/null +++ b/recolecta_app/views/pubspec.lock @@ -0,0 +1,730 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7" + url: "https://pub.dev" + source: hosted + version: "1.3.35" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c" + url: "https://pub.dev" + source: hosted + version: "2.32.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "8bcfad6d7033f5ea951d15b867622a824b13812178bfec0c779b9d81de011bbb" + url: "https://pub.dev" + source: hosted + version: "5.4.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: eb3afccfc452b2b2075acbe0c4b27de62dd596802b4e5e19869c1e926cbb20b3 + url: "https://pub.dev" + source: hosted + version: "2.24.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "980259425fa5e2afc03e533f33723335731d21a56fd255611083bceebf4373a8" + url: "https://pub.dev" + source: hosted + version: "14.7.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "87c4a922cb6f811cfb7a889bdbb3622702443c52a0271636cbc90d813ceac147" + url: "https://pub.dev" + source: hosted + version: "4.5.37" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "90dc7ed885e90a24bb0e56d661d4d2b5f84429697fd2cbb9e5890a0ca370e6f4" + url: "https://pub.dev" + source: hosted + version: "3.5.18" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" + url: "https://pub.dev" + source: hosted + version: "11.1.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + google_maps: + dependency: transitive + description: + name: google_maps + sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" + url: "https://pub.dev" + source: hosted + version: "8.2.0" + google_maps_flutter: + dependency: "direct main" + description: + name: google_maps_flutter + sha256: fc714bf8072e2c121d4277cb6dca23bbfae954b6c7b5d6dd73f1bc8d09762921 + url: "https://pub.dev" + source: hosted + version: "2.17.0" + google_maps_flutter_android: + dependency: transitive + description: + name: google_maps_flutter_android + sha256: f1eb5ffa34ba41f8591e53ce439f78af179a506e8386a1297d0ecd202e05c734 + url: "https://pub.dev" + source: hosted + version: "2.19.8" + google_maps_flutter_ios: + dependency: transitive + description: + name: google_maps_flutter_ios + sha256: "5ed8d8d0f93dfa7f5039c409c500948e98e59068f8f6fcf9105bfd07e3709d7f" + url: "https://pub.dev" + source: hosted + version: "2.18.1" + google_maps_flutter_platform_interface: + dependency: transitive + description: + name: google_maps_flutter_platform_interface + sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 + url: "https://pub.dev" + source: hosted + version: "2.15.0" + google_maps_flutter_web: + dependency: transitive + description: + name: google_maps_flutter_web + sha256: "9b068070bf18b5ec6a7d8ac512c7d557377dbe267658d264d2095b7ee4f1f6c5" + url: "https://pub.dev" + source: hosted + version: "0.6.2+1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + sanitize_html: + dependency: transitive + description: + name: sanitize_html + sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/recolecta_app/views/pubspec.yaml b/recolecta_app/views/pubspec.yaml new file mode 100644 index 0000000..e458a45 --- /dev/null +++ b/recolecta_app/views/pubspec.yaml @@ -0,0 +1,33 @@ +name: rutaverde +description: Rastreo del camión de basura en tiempo real + +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.6 + google_maps_flutter: ^2.5.0 + geolocator: ^11.0.0 + flutter_local_notifications: ^17.0.0 + firebase_core: ^2.24.0 + firebase_messaging: ^14.7.0 + provider: ^6.1.1 + shared_preferences: ^2.2.2 + http: ^1.1.0 + intl: ^0.19.0 + permission_handler: ^11.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + assets: + - assets/images/