Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>
Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.github.com> Co-authored-by: eddgranados12 <eddgranados12@users.noreply.github.com> configuracion inicial para supoabase y endpoints
This commit is contained in:
BIN
animations/blink feliz 1.mp4
Normal file
BIN
animations/blink feliz 1.mp4
Normal file
Binary file not shown.
BIN
animations/blink saludo.mp4
Normal file
BIN
animations/blink saludo.mp4
Normal file
Binary file not shown.
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
secrets/
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
46
backend/FIREBASE_SETUP.md
Normal file
46
backend/FIREBASE_SETUP.md
Normal file
@@ -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.).
|
||||||
27
backend/README.md
Normal file
27
backend/README.md
Normal file
@@ -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
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Inicializa el paquete db
|
||||||
45
backend/app/api/eta.py
Normal file
45
backend/app/api/eta.py
Normal file
@@ -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}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
[
|
[
|
||||||
{ "colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)" },
|
{"colonia": "Zona Centro", "routeId": "RUTA-01", "turno": "Matutino", "horario_estimado": "07:00 - 09:00"},
|
||||||
{ "colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)" },
|
{"colonia": "Las Arboledas", "routeId": "RUTA-01", "turno": "Matutino", "horario_estimado": "08:30 - 10:30"},
|
||||||
{ "colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)" },
|
{"colonia": "San Juanico", "routeId": "RUTA-03", "turno": "Matutino", "horario_estimado": "07:00 - 09:00"},
|
||||||
{ "colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)" },
|
{"colonia": "Los Olivos", "routeId": "RUTA-04", "turno": "Matutino", "horario_estimado": "09:00 - 11:00"},
|
||||||
{ "colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)" },
|
{"colonia": "Rancho Seco", "routeId": "RUTA-05", "turno": "Vespertino", "horario_estimado": "18:00 - 20:00"},
|
||||||
{ "colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)" },
|
{"colonia": "Las Insurgentes", "routeId": "RUTA-12", "turno": "Matutino", "horario_estimado": "10:00 - 12:00"},
|
||||||
{ "colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)" }
|
{"colonia": "Trojes", "routeId": "RUTA-13", "turno": "Matutino", "horario_estimado": "11:00 - 13:00"}
|
||||||
]
|
]
|
||||||
@@ -1,26 +1,14 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"triggerEvent": "ROUTE_START",
|
"triggerEvent": "ROUTE_START",
|
||||||
"condition": "Cuando positionId cambia de 1 a 2",
|
"pushPayload": {"title": "Ruta Iniciada", "body": "El camión salió del relleno rumbo a tu sector."}
|
||||||
"pushPayload": {
|
|
||||||
"title": "¡Ruta Iniciada!",
|
|
||||||
"body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"triggerEvent": "TRUCK_PROXIMITY",
|
"triggerEvent": "TRUCK_PROXIMITY",
|
||||||
"condition": "Cuando positionId llega a 4 (punto previo al destino)",
|
"pushPayload": {"title": "Camión Cerca", "body": "A menos de 15 min; saca tus bolsas."}
|
||||||
"pushPayload": {
|
|
||||||
"title": "Camión Cercano",
|
|
||||||
"body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"triggerEvent": "ROUTE_COMPLETED",
|
"triggerEvent": "ROUTE_COMPLETED",
|
||||||
"condition": "Cuando positionId llega a 8 (retorno al basurero)",
|
"pushPayload": {"title": "Servicio Finalizado", "body": "El servicio del día en tu zona ha concluido."}
|
||||||
"pushPayload": {
|
|
||||||
"title": "Servicio Finalizado",
|
|
||||||
"body": "El camión de tu sector ha concluido su jornada de recolección diaria."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,242 +1,57 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-01",
|
"routeId": "RUTA-01",
|
||||||
"name": "Zona Centro - Las Arboledas",
|
|
||||||
"truckId": 101,
|
"truckId": 101,
|
||||||
"status": "EN_RUTA",
|
"turno": "matutino",
|
||||||
|
"status": "PENDIENTE",
|
||||||
"positions": [
|
"positions": [
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 40, "ts": "2026-05-01T08:00:00Z"},
|
||||||
{ "positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
{"positionId": 2, "lat": 20.5150, "lng": -100.9000, "speed": 35, "ts": "2026-05-01T08:10:00Z"},
|
||||||
{ "positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z" },
|
{"positionId": 3, "lat": 20.5200, "lng": -100.8950, "speed": 20, "ts": "2026-05-01T08:20:00Z"},
|
||||||
{ "positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z" },
|
{"positionId": 4, "lat": 20.5250, "lng": -100.8900, "speed": 15, "ts": "2026-05-01T08:30:00Z"},
|
||||||
{ "positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z" },
|
{"positionId": 5, "lat": 20.5300, "lng": -100.8850, "speed": 0, "ts": "2026-05-01T08:40:00Z"},
|
||||||
{ "positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z" },
|
{"positionId": 6, "lat": 20.5250, "lng": -100.8900, "speed": 25, "ts": "2026-05-01T08:50:00Z"},
|
||||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18: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": 40, "timestamp": "2026-05-22T07:40:00Z" }
|
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 0, "ts": "2026-05-01T09:10:00Z"}
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-02",
|
|
||||||
"name": "Sector Norte - Av. Tecnológico",
|
|
||||||
"truckId": 102,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-03",
|
|
||||||
"name": "Sector Poniente - San Juanico",
|
|
||||||
"truckId": 103,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-04",
|
|
||||||
"name": "Oriente - Los Olivos",
|
|
||||||
"truckId": 104,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z" }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-05",
|
"routeId": "RUTA-05",
|
||||||
"name": "Sector Sur - Rancho Seco",
|
|
||||||
"truckId": 105,
|
"truckId": 105,
|
||||||
"status": "EN_RUTA",
|
"turno": "vespertino",
|
||||||
|
"status": "PENDIENTE",
|
||||||
"positions": [
|
"positions": [
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z" },
|
{"positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 40, "ts": "2026-05-01T15:00:00Z"},
|
||||||
{ "positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z" },
|
{"positionId": 4, "lat": 20.5250, "lng": -100.8900, "speed": 15, "ts": "2026-05-01T15:30:00Z"},
|
||||||
{ "positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z" },
|
{"positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 0, "ts": "2026-05-01T16:10:00Z"}
|
||||||
{ "positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z" }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-06",
|
"routeId": "RUTA-03",
|
||||||
"name": "Norte Extremo - Rumbos de Roque",
|
"truckId": 103,
|
||||||
"truckId": 106,
|
"turno": "matutino",
|
||||||
"status": "EN_RUTA",
|
"status": "PENDIENTE",
|
||||||
"positions": [
|
"positions": []
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-07",
|
"routeId": "RUTA-04",
|
||||||
"name": "Nororiente - Ciudad Industrial",
|
"truckId": 104,
|
||||||
"truckId": 107,
|
"turno": "matutino",
|
||||||
"status": "EN_RUTA",
|
"status": "PENDIENTE",
|
||||||
"positions": [
|
"positions": []
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-08",
|
|
||||||
"name": "Suroriente - Universidad Latina",
|
|
||||||
"truckId": 108,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-09",
|
|
||||||
"name": "Poniente - Hospital General",
|
|
||||||
"truckId": 109,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-10",
|
|
||||||
"name": "Eje Juan Pablo II - Sede UG Sur",
|
|
||||||
"truckId": 110,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"routeId": "RUTA-11",
|
|
||||||
"name": "Zona de Oro - Torres Landa",
|
|
||||||
"truckId": 111,
|
|
||||||
"status": "EN_RUTA",
|
|
||||||
"positions": [
|
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z" },
|
|
||||||
{ "positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-12",
|
"routeId": "RUTA-12",
|
||||||
"name": "Nororiente - Las Insurgentes",
|
|
||||||
"truckId": 112,
|
"truckId": 112,
|
||||||
"status": "EN_RUTA",
|
"turno": "matutino",
|
||||||
"positions": [
|
"status": "PENDIENTE",
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z" },
|
"positions": []
|
||||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z" },
|
|
||||||
{ "positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z" },
|
|
||||||
{ "positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
|
||||||
{ "positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
|
||||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z" },
|
|
||||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z" },
|
|
||||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"routeId": "RUTA-13",
|
"routeId": "RUTA-13",
|
||||||
"name": "Sector Norte - Trojes e Irrigación",
|
|
||||||
"truckId": 113,
|
"truckId": 113,
|
||||||
"status": "EN_RUTA",
|
"turno": "matutino",
|
||||||
"positions": [
|
"status": "PENDIENTE",
|
||||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z" },
|
"positions": []
|
||||||
{ "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" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
107
backend/app/db/seed.py
Normal file
107
backend/app/db/seed.py
Normal file
@@ -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()
|
||||||
18
backend/app/main.py
Normal file
18
backend/app/main.py
Normal file
@@ -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)
|
||||||
9
backend/app/services/__init__.py
Normal file
9
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from .simulation import (
|
||||||
|
load_data,
|
||||||
|
start_simulation_state,
|
||||||
|
get_colonias,
|
||||||
|
get_route_position,
|
||||||
|
get_route_status,
|
||||||
|
tick,
|
||||||
|
get_last_events,
|
||||||
|
)
|
||||||
49
backend/app/services/notifications.py
Normal file
49
backend/app/services/notifications.py
Normal file
@@ -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)}
|
||||||
94
backend/app/services/simulation.py
Normal file
94
backend/app/services/simulation.py
Normal file
@@ -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:]
|
||||||
36
backend/main.py
Normal file
36
backend/main.py
Normal file
@@ -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"}
|
||||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
@@ -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
|
||||||
33
recolecta_app/views/lib/main.dart
Normal file
33
recolecta_app/views/lib/main.dart
Normal file
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
recolecta_app/views/lib/models/models.dart
Normal file
196
recolecta_app/views/lib/models/models.dart
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// ── Usuario ──────────────────────────────────────────────────────────────────
|
||||||
|
class UserModel {
|
||||||
|
final String id;
|
||||||
|
final String nombre;
|
||||||
|
final String apellido;
|
||||||
|
final String email;
|
||||||
|
final String telefono;
|
||||||
|
final List<HouseModel> 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<HouseModel>? 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
388
recolecta_app/views/lib/screens/alerts_screen.dart
Normal file
388
recolecta_app/views/lib/screens/alerts_screen.dart
Normal file
@@ -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<AlertsScreen> createState() => _AlertsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlertsScreenState extends State<AlertsScreen> {
|
||||||
|
// 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<AlertaModel> _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<Color>(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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
495
recolecta_app/views/lib/screens/house_screen.dart
Normal file
495
recolecta_app/views/lib/screens/house_screen.dart
Normal file
@@ -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<MyHouseScreen> createState() => _MyHouseScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyHouseScreenState extends State<MyHouseScreen> {
|
||||||
|
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<int> 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<bool> onAlertaCercanaChanged;
|
||||||
|
final ValueChanged<bool> onAlertaMediaChanged;
|
||||||
|
final ValueChanged<bool> 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
recolecta_app/views/lib/screens/login_screen.dart
Normal file
244
recolecta_app/views/lib/screens/login_screen.dart
Normal file
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailCtrl = TextEditingController();
|
||||||
|
final _passCtrl = TextEditingController();
|
||||||
|
bool _obscurePass = true;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_passCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
recolecta_app/views/lib/screens/main_shell.dart
Normal file
38
recolecta_app/views/lib/screens/main_shell.dart
Normal file
@@ -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<MainShell> createState() => _MainShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainShellState extends State<MainShell> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
383
recolecta_app/views/lib/screens/map_screen.dart
Normal file
383
recolecta_app/views/lib/screens/map_screen.dart
Normal file
@@ -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<MapScreen> createState() => _MapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapScreenState extends State<MapScreen> {
|
||||||
|
final Completer<GoogleMapController> _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<Marker> _markers = {};
|
||||||
|
Set<Circle> _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<void> _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<Color>(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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
recolecta_app/views/lib/screens/profile_screen.dart
Normal file
234
recolecta_app/views/lib/screens/profile_screen.dart
Normal file
@@ -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: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
541
recolecta_app/views/lib/screens/register_screen.dart
Normal file
541
recolecta_app/views/lib/screens/register_screen.dart
Normal file
@@ -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<RegisterScreen> createState() => _RegisterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
|
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<void> _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<int> 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<int> 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
recolecta_app/views/lib/screens/splash_screen.dart
Normal file
237
recolecta_app/views/lib/screens/splash_screen.dart
Normal file
@@ -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<SplashScreen> createState() => _SplashScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashScreenState extends State<SplashScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _fadeIn;
|
||||||
|
late Animation<Offset> _slideUp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
);
|
||||||
|
_fadeIn = Tween<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
_slideUp = Tween<Offset>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
recolecta_app/views/lib/theme/app_theme.dart
Normal file
124
recolecta_app/views/lib/theme/app_theme.dart
Normal file
@@ -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<BoxShadow> get cardShadow => [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
382
recolecta_app/views/lib/widgets/widgets.dart
Normal file
382
recolecta_app/views/lib/widgets/widgets.dart
Normal file
@@ -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<bool> 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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
730
recolecta_app/views/pubspec.lock
Normal file
730
recolecta_app/views/pubspec.lock
Normal file
@@ -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"
|
||||||
33
recolecta_app/views/pubspec.yaml
Normal file
33
recolecta_app/views/pubspec.yaml
Normal file
@@ -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/
|
||||||
Reference in New Issue
Block a user