Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475cf60d61 | ||
|
|
8733aaf91f | ||
|
|
6eaa8d2afa | ||
|
|
feead21e73 | ||
|
|
1886ab6094 | ||
|
|
451e005c30 | ||
|
|
fa44503311 | ||
|
|
47f4a7d2b1 | ||
|
|
6d1845c09d | ||
|
|
a38ca14f38 | ||
|
|
ff90f3eefc | ||
|
|
327852e468 | ||
|
|
e6eb466c14 |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"^d|\\\\.dart$|\\\\.py$|pubspec|requirements\")",
|
||||
"Bash(rm -rf basura_app/lib/features/data)",
|
||||
"Bash(rm -rf basura_app/lib/features/domain)",
|
||||
"Bash(rm -rf basura_app/lib/features/presentation)",
|
||||
"Bash(rm -rf basura_app/lib/core)",
|
||||
"Bash(rm -rf lib/src)",
|
||||
"Bash(cp -r basura_app/lib/* lib/)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git push *)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Read(//mnt/c/Users/Wallt/StudioProjects/**)",
|
||||
"Bash(flutter pub *)",
|
||||
"Bash(flutter analyze *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar requirements
|
||||
COPY server/requirements.txt .
|
||||
|
||||
# Instalar dependencias
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar app
|
||||
COPY server/app ./app
|
||||
|
||||
# Copiar config
|
||||
COPY server/.env.example ./app/.env
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
|
||||
|
||||
# Correr app
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
207
SETUP.md
Normal file
207
SETUP.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Setup & Demo Path
|
||||
|
||||
## 1. Backend (Python FastAPI)
|
||||
|
||||
### Requisitos
|
||||
- Python 3.11+
|
||||
- pip
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
cd server
|
||||
python -m venv venv
|
||||
source venv/Scripts/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Configurar .env
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Editar .env con:
|
||||
SECRET_KEY=tu-secret-key-cambiar-en-produccion
|
||||
SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co
|
||||
SUPABASE_ANON_KEY=sb_publishable_FQR0WXK6joM043Qve9gz3A_pJfAH...
|
||||
SUPABASE_SERVICE_KEY=sb_secret_2y3a_...
|
||||
```
|
||||
|
||||
### Correr
|
||||
```bash
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Servidor en: http://localhost:8000
|
||||
Docs: http://localhost:8000/docs
|
||||
|
||||
---
|
||||
|
||||
## 2. Flutter (Frontend)
|
||||
|
||||
### Requisitos
|
||||
- Flutter 3.44+
|
||||
- Android Studio / Xcode (para emulador)
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### Correr
|
||||
```bash
|
||||
flutter run -d windows # o -d chrome, -d emulator-5554
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Demo Path (3 pasos)
|
||||
|
||||
### Paso 1: Register
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "demo@basura.app",
|
||||
"phone": "4611234567",
|
||||
"password": "demo123"
|
||||
}'
|
||||
```
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "bearer",
|
||||
"user_id": "uuid-aqui"
|
||||
}
|
||||
```
|
||||
|
||||
Guardar `access_token`.
|
||||
|
||||
---
|
||||
|
||||
### Paso 2: Create Address
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/addresses/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_AQUI" \
|
||||
-d '{
|
||||
"lat": 20.5285,
|
||||
"lng": -100.7980,
|
||||
"alias": "Casa",
|
||||
"address_text": "Celaya, Gto"
|
||||
}'
|
||||
```
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": "uuid",
|
||||
"alias": "Casa",
|
||||
"lat": 20.5285,
|
||||
"lng": -100.7980,
|
||||
"route_id": "RUTA-01"
|
||||
}
|
||||
```
|
||||
|
||||
Guardar `address_id`.
|
||||
|
||||
---
|
||||
|
||||
### Paso 3: Get ETA
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/eta/1 \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_AQUI"
|
||||
```
|
||||
|
||||
**Respuesta:**
|
||||
```json
|
||||
{
|
||||
"address_id": 1,
|
||||
"route_id": "RUTA-01",
|
||||
"status": "EN_RUTA",
|
||||
"eta_minutos": 15,
|
||||
"ventana": {
|
||||
"inicio": "10:00 AM",
|
||||
"fin": "10:15 AM"
|
||||
},
|
||||
"mensaje": "El camión está en camino. Llegada estimada: 10:00 AM – 10:15 AM.",
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Flutter Test (UI)
|
||||
|
||||
1. **Login**: email=`demo@basura.app`, password=`demo123`
|
||||
2. **Home**: Debe mostrar 2 botones:
|
||||
- 📚 Guía de Reciclaje
|
||||
- 🚚 Rutas & ETA
|
||||
3. **Rutas**: Click en 🚚 → Ver ETA de la ruta RUTA-01
|
||||
4. **Guía**: Click en 📚 → Ver categorías de reciclaje
|
||||
|
||||
---
|
||||
|
||||
## 5. Deploy en Railway
|
||||
|
||||
### Crear proyecto Railway
|
||||
```bash
|
||||
npm i -g @railway/cli
|
||||
railway login
|
||||
railway init
|
||||
```
|
||||
|
||||
### Configurar variables de entorno en Railway Dashboard
|
||||
```
|
||||
SUPABASE_URL=...
|
||||
SUPABASE_ANON_KEY=...
|
||||
SUPABASE_SERVICE_KEY=...
|
||||
SECRET_KEY=...
|
||||
```
|
||||
|
||||
### Deploy
|
||||
```bash
|
||||
railway up
|
||||
```
|
||||
|
||||
Backend en vivo: `https://your-railway-project.up.railway.app`
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Automatizado
|
||||
|
||||
### Terminal 1: Correr backend
|
||||
```bash
|
||||
bash run_backend.sh
|
||||
```
|
||||
|
||||
Esperar a:
|
||||
```
|
||||
Uvicorn running on http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### Terminal 2: Correr test
|
||||
```bash
|
||||
bash test_demo.sh
|
||||
```
|
||||
|
||||
**Output esperado:**
|
||||
```
|
||||
✓ Register OK
|
||||
✓ Create Address OK
|
||||
✓ Get ETA OK
|
||||
✓ Guide OK
|
||||
=== ALL TESTS PASSED ===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problema | Solución |
|
||||
|----------|----------|
|
||||
| `ModuleNotFoundError: No module named 'app'` | Estar en carpeta `server/` |
|
||||
| `SUPABASE connection refused` | Verificar SUPABASE_URL y keys en .env |
|
||||
| `JWT token invalid` | Token expiró o SECRET_KEY no coincide |
|
||||
| `Flutter pub get error` | Eliminar `pubspec.lock` y correr `flutter clean` |
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="proyecto_flutter"
|
||||
android:name="${applicationName}"
|
||||
|
||||
70
assets/recycling_guide.json
Normal file
70
assets/recycling_guide.json
Normal file
@@ -0,0 +1,70 @@
|
||||
[
|
||||
{
|
||||
"id": "organicos",
|
||||
"nombre": "Orgánicos",
|
||||
"descripcion": "Residuos de origen natural que se descomponen",
|
||||
"color": "#4CAF50",
|
||||
"icono": "eco",
|
||||
"consejo": "Pueden convertirse en composta. Sepáralos en una bolsa o bote verde.",
|
||||
"items": [
|
||||
{ "nombre": "Cáscaras de fruta", "ejemplos": "naranja, plátano, manzana", "acepta": true },
|
||||
{ "nombre": "Sobras de comida", "ejemplos": "arroz, frijoles, tortillas", "acepta": true },
|
||||
{ "nombre": "Verduras y hortalizas", "ejemplos": "jitomate, lechuga, zanahoria", "acepta": true },
|
||||
{ "nombre": "Cáscaras de huevo", "ejemplos": "huevo de gallina", "acepta": true },
|
||||
{ "nombre": "Posos de café y té", "ejemplos": "filtros de papel, hojas de té", "acepta": true },
|
||||
{ "nombre": "Pañuelos desechables usados", "ejemplos": "kleenex con comida", "acepta": false },
|
||||
{ "nombre": "Carne y huesos", "ejemplos": "res, pollo, cerdo", "acepta": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reciclables",
|
||||
"nombre": "Reciclables",
|
||||
"descripcion": "Materiales que pueden transformarse en nuevos productos",
|
||||
"color": "#2196F3",
|
||||
"icono": "recycling",
|
||||
"consejo": "Enjuaga envases antes de depositarlos. Separa por material cuando puedas.",
|
||||
"items": [
|
||||
{ "nombre": "Botellas PET", "ejemplos": "agua, refresco, jugos", "acepta": true },
|
||||
{ "nombre": "Latas de aluminio", "ejemplos": "refresco, cerveza, atún", "acepta": true },
|
||||
{ "nombre": "Cartón y papel", "ejemplos": "cajas, periódico, revistas", "acepta": true },
|
||||
{ "nombre": "Vidrio", "ejemplos": "botellas, frascos de vidrio limpio", "acepta": true },
|
||||
{ "nombre": "Plástico duro", "ejemplos": "botes de detergente, galones", "acepta": true },
|
||||
{ "nombre": "Papel encerado o plastificado", "ejemplos": "vasos de cartón con cera", "acepta": false },
|
||||
{ "nombre": "Envases con restos de comida", "ejemplos": "latas sin lavar", "acepta": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sanitarios",
|
||||
"nombre": "Sanitarios",
|
||||
"descripcion": "Residuos con riesgo de contaminación biológica",
|
||||
"color": "#FF5722",
|
||||
"icono": "masks",
|
||||
"consejo": "Envuélvelos bien antes de depositarlos. Nunca los mezcles con reciclables.",
|
||||
"items": [
|
||||
{ "nombre": "Pañales desechables", "ejemplos": "pañales de bebé o adulto", "acepta": true },
|
||||
{ "nombre": "Toallas sanitarias", "ejemplos": "toallas femeninas, protectores", "acepta": true },
|
||||
{ "nombre": "Papel higiénico usado", "ejemplos": "papel de baño", "acepta": true },
|
||||
{ "nombre": "Pañuelos desechables", "ejemplos": "kleenex con fluidos", "acepta": true },
|
||||
{ "nombre": "Cubrebocas y guantes usados", "ejemplos": "mascarillas, látex", "acepta": true },
|
||||
{ "nombre": "Jeringas o agujas", "ejemplos": "material punzocortante", "acepta": false },
|
||||
{ "nombre": "Medicamentos vencidos", "ejemplos": "pastillas, jarabes", "acepta": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "especiales",
|
||||
"nombre": "Especiales",
|
||||
"descripcion": "Residuos peligrosos que requieren manejo diferenciado",
|
||||
"color": "#FF9800",
|
||||
"icono": "warning_amber",
|
||||
"consejo": "Lleva estos residuos a puntos de recolección especializados. Nunca a la basura común.",
|
||||
"items": [
|
||||
{ "nombre": "Pilas y baterías", "ejemplos": "AA, AAA, de celular", "acepta": false },
|
||||
{ "nombre": "Aceite vegetal usado", "ejemplos": "aceite de cocina", "acepta": false },
|
||||
{ "nombre": "Pinturas y solventes", "ejemplos": "thinner, pintura vinílica", "acepta": false },
|
||||
{ "nombre": "Electrónicos", "ejemplos": "celulares, computadoras, cables", "acepta": false },
|
||||
{ "nombre": "Focos ahorradores", "ejemplos": "lámparas fluorescentes", "acepta": false },
|
||||
{ "nombre": "Cartuchos de tinta", "ejemplos": "tinta de impresora", "acepta": false },
|
||||
{ "nombre": "Aerosoles vacíos", "ejemplos": "desodorantes, pinturas en spray", "acepta": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
242
assets/rutas.json
Normal file
242
assets/rutas.json
Normal file
@@ -0,0 +1,242 @@
|
||||
[
|
||||
{
|
||||
"routeId": "RUTA-01",
|
||||
"name": "Zona Centro - Las Arboledas",
|
||||
"truckId": 101,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-02",
|
||||
"name": "Sector Norte - Av. Tecnológico",
|
||||
"truckId": 102,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-03",
|
||||
"name": "Sector Poniente - San Juanico",
|
||||
"truckId": 103,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-04",
|
||||
"name": "Oriente - Los Olivos",
|
||||
"truckId": 104,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-05",
|
||||
"name": "Sector Sur - Rancho Seco",
|
||||
"truckId": 105,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-06",
|
||||
"name": "Norte Extremo - Rumbos de Roque",
|
||||
"truckId": 106,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-07",
|
||||
"name": "Nororiente - Ciudad Industrial",
|
||||
"truckId": 107,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-08",
|
||||
"name": "Suroriente - Universidad Latina",
|
||||
"truckId": 108,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-09",
|
||||
"name": "Poniente - Hospital General",
|
||||
"truckId": 109,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-10",
|
||||
"name": "Eje Juan Pablo II - Sede UG Sur",
|
||||
"truckId": 110,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z" },
|
||||
{ "positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z" },
|
||||
{ "positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z" },
|
||||
{ "positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-11",
|
||||
"name": "Zona de Oro - Torres Landa",
|
||||
"truckId": 111,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-12",
|
||||
"name": "Nororiente - Las Insurgentes",
|
||||
"truckId": 112,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-13",
|
||||
"name": "Sector Norte - Trojes e Irrigación",
|
||||
"truckId": 113,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-14",
|
||||
"name": "Sur Poniente - La Toscana",
|
||||
"truckId": 114,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-15",
|
||||
"name": "Norponiente - Camino a San José de Celaya",
|
||||
"truckId": 115,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
}
|
||||
]
|
||||
2
lib/core/config/supabase_config.dart
Normal file
2
lib/core/config/supabase_config.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
const String SUPABASE_URL = 'https://qckndtzudciejpnwqfzt.supabase.co';
|
||||
const String SUPABASE_ANON_KEY = 'sb_secret_2y3a_9qD5nRtZl-41CY-jw_LA-smvxC';
|
||||
119
lib/core/supabase_service.dart
Normal file
119
lib/core/supabase_service.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
final supabaseClient = Supabase.instance.client;
|
||||
|
||||
class RutasService {
|
||||
final SupabaseClient _client = supabaseClient;
|
||||
|
||||
// ── Obtener ruta por ID ──
|
||||
Future<Map<String, dynamic>?> obtenerRuta(String routeId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('rutas')
|
||||
.select('*')
|
||||
.eq('id', routeId)
|
||||
.single();
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_ruta: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obtener puntos de ruta ──
|
||||
Future<List<Map<String, dynamic>>> obtenerPuntosRuta(String routeId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('puntos_ruta')
|
||||
.select('*')
|
||||
.eq('ruta_id', routeId)
|
||||
.order('orden');
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_puntos_ruta: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obtener truck status ──
|
||||
Future<Map<String, dynamic>?> obtenerTruckStatus(String routeId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('truck_status')
|
||||
.select('*')
|
||||
.eq('route_id', routeId)
|
||||
.single();
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_truck_status: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obtener template de notificación ──
|
||||
Future<Map<String, dynamic>?> obtenerTemplate(String triggerEvent) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('notification_templates')
|
||||
.select('*')
|
||||
.eq('trigger_event', triggerEvent)
|
||||
.single();
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_template: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obtener preferencias de usuario ──
|
||||
Future<Map<String, dynamic>?> obtenerPreferencias(String userId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('notification_preferences')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_preferencias: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Obtener dirección de usuario ──
|
||||
Future<Map<String, dynamic>?> obtenerDireccion(int addressId) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.from('addresses')
|
||||
.select('*')
|
||||
.eq('id', addressId)
|
||||
.single();
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('Error obtener_direccion: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Guardar notificación ──
|
||||
Future<void> guardarNotificacion({
|
||||
required String tipo,
|
||||
required String routeId,
|
||||
required int addressId,
|
||||
required String mensaje,
|
||||
int? etaMinutos,
|
||||
}) async {
|
||||
try {
|
||||
await _client.from('notificaciones').insert({
|
||||
'tipo': tipo,
|
||||
'ruta_id': routeId,
|
||||
'address_id': addressId,
|
||||
'mensaje': mensaje,
|
||||
'eta_minutos': etaMinutos,
|
||||
'creada_en': DateTime.now().toIso8601String(),
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error guardar_notificacion: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/core/ws_provider.dart
Normal file
86
lib/core/ws_provider.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
|
||||
// Estado del WebSocket
|
||||
class WSState {
|
||||
final bool connected;
|
||||
final String? error;
|
||||
final dynamic lastMessage;
|
||||
|
||||
const WSState({
|
||||
this.connected = false,
|
||||
this.error,
|
||||
this.lastMessage,
|
||||
});
|
||||
|
||||
WSState copyWith({
|
||||
bool? connected,
|
||||
String? error,
|
||||
dynamic lastMessage,
|
||||
}) {
|
||||
return WSState(
|
||||
connected: connected ?? this.connected,
|
||||
error: error,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WSNotifier extends StateNotifier<WSState> {
|
||||
WebSocketChannel? _channel;
|
||||
final String baseUrl;
|
||||
|
||||
WSNotifier(this.baseUrl) : super(const WSState());
|
||||
|
||||
// Conectar a WebSocket de una dirección
|
||||
Future<void> connect(String token, int addressId) async {
|
||||
try {
|
||||
final wsUrl = baseUrl.replaceFirst('https', 'wss').replaceFirst('http', 'ws');
|
||||
final url = Uri.parse('$wsUrl/eta/ws/$addressId');
|
||||
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
|
||||
// Escuchar mensajes
|
||||
_channel?.stream.listen(
|
||||
(message) {
|
||||
state = state.copyWith(
|
||||
lastMessage: message,
|
||||
connected: true,
|
||||
error: null,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
state = state.copyWith(
|
||||
connected: false,
|
||||
error: error.toString(),
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
state = state.copyWith(connected: false);
|
||||
},
|
||||
);
|
||||
|
||||
state = state.copyWith(connected: true, error: null);
|
||||
} catch (e) {
|
||||
state = state.copyWith(connected: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar mensaje
|
||||
void send(String message) {
|
||||
if (_channel != null && state.connected) {
|
||||
_channel!.sink.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Desconectar
|
||||
void disconnect() {
|
||||
_channel?.sink.close(status.goingAway);
|
||||
state = state.copyWith(connected: false);
|
||||
}
|
||||
}
|
||||
|
||||
final wsProvider = StateNotifierProvider<WSNotifier, WSState>((ref) {
|
||||
return WSNotifier('http://localhost:8000'); // Cambiar a URL de producción
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
// lib/features/recycling_guide/data/datasources/recycling_local_datasource.dart
|
||||
// Único archivo que sabe que los datos vienen de un JSON en assets.
|
||||
// Funciona sin conexión a internet.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../domain/entities/recycling_category.dart';
|
||||
|
||||
class RecyclingLocalDatasource {
|
||||
static const _assetPath = 'assets/recycling_guide.json';
|
||||
|
||||
// Cache en memoria — se carga una sola vez durante la sesión
|
||||
List<RecyclingCategory>? _cache;
|
||||
|
||||
Future<List<RecyclingCategory>> cargarCategorias() async {
|
||||
if (_cache != null) return _cache!;
|
||||
|
||||
final raw = await rootBundle.loadString(_assetPath);
|
||||
final List<dynamic> json = jsonDecode(raw);
|
||||
|
||||
_cache = json.map(_mapearCategoria).toList();
|
||||
return _cache!;
|
||||
}
|
||||
|
||||
RecyclingCategory _mapearCategoria(dynamic json) {
|
||||
final items = (json['items'] as List)
|
||||
.map(
|
||||
(i) => RecyclingItem(
|
||||
nombre: i['nombre'] as String,
|
||||
ejemplos: i['ejemplos'] as String,
|
||||
acepta: i['acepta'] as bool,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return RecyclingCategory(
|
||||
id: json['id'] as String,
|
||||
nombre: json['nombre'] as String,
|
||||
descripcion: json['descripcion'] as String,
|
||||
colorHex: json['color'] as String,
|
||||
icono: json['icono'] as String,
|
||||
consejo: json['consejo'] as String,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// lib/features/recycling_guide/data/repositories/recycling_repository.dart
|
||||
|
||||
import '../datasources/recycling_local_datasource.dart';
|
||||
import '../../domain/entities/recycling_category.dart';
|
||||
|
||||
class RecyclingRepository {
|
||||
final RecyclingLocalDatasource _datasource;
|
||||
|
||||
RecyclingRepository({RecyclingLocalDatasource? datasource})
|
||||
: _datasource = datasource ?? RecyclingLocalDatasource();
|
||||
|
||||
Future<List<RecyclingCategory>> obtenerCategorias() =>
|
||||
_datasource.cargarCategorias();
|
||||
|
||||
/// Busca en nombres y ejemplos de todos los items de todas las categorías.
|
||||
/// Devuelve pares (categoría, item) para que la UI sepa dónde mostrar el resultado.
|
||||
Future<List<SearchResult>> buscar(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
final q = query.toLowerCase();
|
||||
final categorias = await obtenerCategorias();
|
||||
final resultados = <SearchResult>[];
|
||||
|
||||
for (final cat in categorias) {
|
||||
for (final item in cat.items) {
|
||||
final coincide = item.nombre.toLowerCase().contains(q) ||
|
||||
item.ejemplos.toLowerCase().contains(q);
|
||||
if (coincide) {
|
||||
resultados.add(SearchResult(categoria: cat, item: item));
|
||||
}
|
||||
}
|
||||
}
|
||||
return resultados;
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResult {
|
||||
final RecyclingCategory categoria;
|
||||
final RecyclingItem item;
|
||||
|
||||
const SearchResult({required this.categoria, required this.item});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// lib/features/recycling_guide/domain/entities/recycling_category.dart
|
||||
// Capa de dominio — cero dependencias de Flutter o paquetes externos.
|
||||
|
||||
class RecyclingItem {
|
||||
final String nombre;
|
||||
final String ejemplos;
|
||||
final bool acepta; // true = sí va aquí, false = NO va aquí
|
||||
|
||||
const RecyclingItem({
|
||||
required this.nombre,
|
||||
required this.ejemplos,
|
||||
required this.acepta,
|
||||
});
|
||||
}
|
||||
|
||||
class RecyclingCategory {
|
||||
final String id;
|
||||
final String nombre;
|
||||
final String descripcion;
|
||||
final String colorHex;
|
||||
final String icono;
|
||||
final String consejo;
|
||||
final List<RecyclingItem> items;
|
||||
|
||||
const RecyclingCategory({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
required this.descripcion,
|
||||
required this.colorHex,
|
||||
required this.icono,
|
||||
required this.consejo,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// Items que SÍ van en esta categoría
|
||||
List<RecyclingItem> get itemsAceptados =>
|
||||
items.where((i) => i.acepta).toList();
|
||||
|
||||
/// Items que NO van en esta categoría
|
||||
List<RecyclingItem> get itemsRechazados =>
|
||||
items.where((i) => !i.acepta).toList();
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// lib/features/recycling_guide/presentation/providers/recycling_provider.dart
|
||||
// Riverpod — compatible con lo que usa Persona C en el resto de la app.
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/repositories/recycling_repository.dart';
|
||||
import '../../domain/entities/recycling_category.dart';
|
||||
|
||||
// ── Repositorio singleton ────────────────────────────────────────────
|
||||
final recyclingRepositoryProvider = Provider<RecyclingRepository>(
|
||||
(ref) => RecyclingRepository(),
|
||||
);
|
||||
|
||||
// ── Categorías (carga inicial desde JSON) ────────────────────────────
|
||||
final recyclingCategoriesProvider =
|
||||
FutureProvider<List<RecyclingCategory>>((ref) {
|
||||
return ref.watch(recyclingRepositoryProvider).obtenerCategorias();
|
||||
});
|
||||
|
||||
// ── Estado del buscador ──────────────────────────────────────────────
|
||||
class RecyclingSearchNotifier extends StateNotifier<RecyclingSearchState> {
|
||||
final RecyclingRepository _repo;
|
||||
|
||||
RecyclingSearchNotifier(this._repo)
|
||||
: super(const RecyclingSearchState.idle());
|
||||
|
||||
Future<void> buscar(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
state = const RecyclingSearchState.idle();
|
||||
return;
|
||||
}
|
||||
state = const RecyclingSearchState.loading();
|
||||
final resultados = await _repo.buscar(query);
|
||||
state = RecyclingSearchState.done(resultados);
|
||||
}
|
||||
|
||||
void limpiar() => state = const RecyclingSearchState.idle();
|
||||
}
|
||||
|
||||
final recyclingSearchProvider =
|
||||
StateNotifierProvider<RecyclingSearchNotifier, RecyclingSearchState>((ref) {
|
||||
return RecyclingSearchNotifier(ref.watch(recyclingRepositoryProvider));
|
||||
});
|
||||
|
||||
// ── Estado sellado del buscador ──────────────────────────────────────
|
||||
sealed class RecyclingSearchState {
|
||||
const RecyclingSearchState();
|
||||
|
||||
const factory RecyclingSearchState.idle() = Idle;
|
||||
const factory RecyclingSearchState.loading() = Loading;
|
||||
const factory RecyclingSearchState.done(List<SearchResult> results) = Done;
|
||||
}
|
||||
|
||||
class Idle extends RecyclingSearchState {
|
||||
const Idle();
|
||||
}
|
||||
|
||||
class Loading extends RecyclingSearchState {
|
||||
const Loading();
|
||||
}
|
||||
|
||||
class Done extends RecyclingSearchState {
|
||||
final List<SearchResult> results;
|
||||
const Done(this.results);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// lib/features/recycling_guide/presentation/screens/category_detail_screen.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/recycling_category.dart';
|
||||
|
||||
class CategoryDetailScreen extends StatelessWidget {
|
||||
final RecyclingCategory categoria;
|
||||
|
||||
const CategoryDetailScreen({super.key, required this.categoria});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _parseColor(categoria.colorHex);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Header expandible ──────────────────────────────────
|
||||
SliverAppBar(
|
||||
expandedHeight: 180,
|
||||
pinned: true,
|
||||
backgroundColor: color,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: Text(
|
||||
categoria.nombre,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [color, color.withOpacity(0.7)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_iconoDesdeNombre(categoria.icono),
|
||||
size: 80,
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Descripción
|
||||
Text(
|
||||
categoria.descripcion,
|
||||
style: TextStyle(fontSize: 15, color: Colors.grey[700]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Consejo destacado
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.tips_and_updates, color: color, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
categoria.consejo,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Sección: SÍ van aquí
|
||||
if (categoria.itemsAceptados.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'Sí van aquí',
|
||||
icon: Icons.check_circle,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...categoria.itemsAceptados.map(
|
||||
(item) => _ItemTile(item: item, acepta: true),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Sección: NO van aquí
|
||||
if (categoria.itemsRechazados.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
label: 'No van aquí',
|
||||
icon: Icons.cancel,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...categoria.itemsRechazados.map(
|
||||
(item) => _ItemTile(item: item, acepta: false),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _parseColor(String hex) {
|
||||
final h = hex.replaceFirst('#', '');
|
||||
return Color(int.parse('FF$h', radix: 16));
|
||||
}
|
||||
|
||||
IconData _iconoDesdeNombre(String nombre) {
|
||||
return switch (nombre) {
|
||||
'eco' => Icons.eco,
|
||||
'recycling' => Icons.recycling,
|
||||
'masks' => Icons.masks,
|
||||
'warning_amber' => Icons.warning_amber,
|
||||
_ => Icons.category,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemTile extends StatelessWidget {
|
||||
final RecyclingItem item;
|
||||
final bool acepta;
|
||||
|
||||
const _ItemTile({required this.item, required this.acepta});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
acepta ? Icons.check : Icons.close,
|
||||
size: 16,
|
||||
color: acepta ? Colors.green : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.nombre,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.ejemplos,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// lib/features/recycling_guide/presentation/screens/recycling_guide_screen.dart
|
||||
// Pantalla principal — Persona C la agrega al router así:
|
||||
// GoRoute(path: '/guia', builder: (_, __) => const RecyclingGuideScreen())
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/recycling_provider.dart';
|
||||
import '../widgets/category_card.dart';
|
||||
import '../widgets/search_result_tile.dart';
|
||||
import 'category_detail_screen.dart';
|
||||
|
||||
class RecyclingGuideScreen extends ConsumerStatefulWidget {
|
||||
const RecyclingGuideScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RecyclingGuideScreen> createState() =>
|
||||
_RecyclingGuideScreenState();
|
||||
}
|
||||
|
||||
class _RecyclingGuideScreenState extends ConsumerState<RecyclingGuideScreen> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
bool _buscando = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
setState(() => _buscando = query.trim().isNotEmpty);
|
||||
ref.read(recyclingSearchProvider.notifier).buscar(query);
|
||||
}
|
||||
|
||||
void _limpiarBusqueda() {
|
||||
_searchCtrl.clear();
|
||||
setState(() => _buscando = false);
|
||||
ref.read(recyclingSearchProvider.notifier).limpiar();
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Guía de separación'),
|
||||
actions: [
|
||||
// Badge offline — refuerza que funciona sin internet
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.offline_bolt, size: 14, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'sin internet',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// ── Buscador ────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: '¿Dónde va el aceite? ¿y la pila?',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _buscando
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _limpiarBusqueda,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Contenido dinámico ──────────────────────────────────
|
||||
Expanded(
|
||||
child: _buscando
|
||||
? _SearchResults()
|
||||
: _CategoryList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lista de categorías ──────────────────────────────────────────────
|
||||
|
||||
class _CategoryList extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(recyclingCategoriesProvider);
|
||||
|
||||
return async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (categorias) => ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: categorias.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) => CategoryCard(
|
||||
categoria: categorias[i],
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CategoryDetailScreen(categoria: categorias[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resultados de búsqueda ───────────────────────────────────────────
|
||||
|
||||
class _SearchResults extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final estado = ref.watch(recyclingSearchProvider);
|
||||
|
||||
if (estado is Idle) {
|
||||
return const SizedBox.shrink();
|
||||
} else if (estado is Loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (estado is Done) {
|
||||
if (estado.results.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 48, color: Colors.grey[400]),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No encontramos ese residuo.',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: estado.results.length,
|
||||
itemBuilder: (_, i) => SearchResultTile(resultado: estado.results[i]),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// lib/features/recycling_guide/presentation/widgets/category_card.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/recycling_category.dart';
|
||||
|
||||
class CategoryCard extends StatelessWidget {
|
||||
final RecyclingCategory categoria;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const CategoryCard({
|
||||
super.key,
|
||||
required this.categoria,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _parseColor(categoria.colorHex);
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Ícono con fondo coloreado
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_iconoDesdeNombre(categoria.icono),
|
||||
color: color,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Texto
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
categoria.nombre,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
categoria.descripcion,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Chip contador de items
|
||||
Row(
|
||||
children: [
|
||||
_CountChip(
|
||||
count: categoria.itemsAceptados.length,
|
||||
label: 'van aquí',
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CountChip(
|
||||
count: categoria.itemsRechazados.length,
|
||||
label: 'no van',
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _parseColor(String hex) {
|
||||
final h = hex.replaceFirst('#', '');
|
||||
return Color(int.parse('FF$h', radix: 16));
|
||||
}
|
||||
|
||||
IconData _iconoDesdeNombre(String nombre) {
|
||||
return switch (nombre) {
|
||||
'eco' => Icons.eco,
|
||||
'recycling' => Icons.recycling,
|
||||
'masks' => Icons.masks,
|
||||
'warning_amber'=> Icons.warning_amber,
|
||||
_ => Icons.category,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _CountChip extends StatelessWidget {
|
||||
final int count;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _CountChip({
|
||||
required this.count,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$count $label',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// lib/features/recycling_guide/presentation/widgets/search_result_tile.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../data/repositories/recycling_repository.dart';
|
||||
|
||||
class SearchResultTile extends StatelessWidget {
|
||||
final SearchResult resultado;
|
||||
|
||||
const SearchResultTile({super.key, required this.resultado});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _parseColor(resultado.categoria.colorHex);
|
||||
final acepta = resultado.item.acepta;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
acepta ? Icons.check_circle : Icons.cancel,
|
||||
color: acepta ? Colors.green : Colors.red,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
resultado.item.nombre,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
resultado.item.ejemplos,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
resultado.categoria.nombre,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _parseColor(String hex) {
|
||||
final h = hex.replaceFirst('#', '');
|
||||
return Color(int.parse('FF$h', radix: 16));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'features/recycling_guide/presentation/screens/recycling_guide_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Basura App',
|
||||
theme: AppTheme.lightTheme,
|
||||
home: const RecyclingGuideScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
157
lib/src/main.dart
Normal file
157
lib/src/main.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../core/config/supabase_config.dart';
|
||||
import '../core/ws_provider.dart';
|
||||
import 'views/rutas.dart';
|
||||
import 'views/login.dart';
|
||||
import 'views/home_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await Supabase.initialize(
|
||||
url: SUPABASE_URL,
|
||||
anonKey: SUPABASE_ANON_KEY,
|
||||
);
|
||||
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Hackaton App',
|
||||
theme: ThemeData(
|
||||
fontFamily: 'Roboto',
|
||||
scaffoldBackgroundColor: const Color(0xFF0F0D38),
|
||||
),
|
||||
home: const RegistroView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RegistroView extends StatelessWidget {
|
||||
const RegistroView({super.key});
|
||||
|
||||
static const Color colorAzul = Color(0xFF0F0D38);
|
||||
static const Color colorVerde = Color(0xFF2E4D31);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorAzul,
|
||||
title: const Text(
|
||||
'Registro',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 28,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(30.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
_buildInput(Icons.person_outline, 'Nombre Completo'),
|
||||
_buildInput(Icons.email_outlined, 'Correo'),
|
||||
_buildInput(Icons.lock_outline, 'Contraseña'),
|
||||
const SizedBox(height: 50),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorAzul,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 30, vertical: 15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const HomeScreen()),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'Registrar',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorAzul,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 30, vertical: 15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const LoginView()),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'¿Tienes cuenta?',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(IconData icon, String hint) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black, width: 2),
|
||||
),
|
||||
child: Icon(icon, size: 40, color: Colors.black),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
color: colorVerde.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 15),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: const BorderSide(color: colorVerde, width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/src/models/domicilio_model.dart
Normal file
54
lib/src/models/domicilio_model.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class Domicilio {
|
||||
final String id;
|
||||
final String nombre;
|
||||
final String colonia;
|
||||
final String calle;
|
||||
final String numero;
|
||||
final double latitud;
|
||||
final double longitud;
|
||||
|
||||
Domicilio({
|
||||
required this.id,
|
||||
required this.nombre,
|
||||
required this.colonia,
|
||||
required this.calle,
|
||||
required this.numero,
|
||||
required this.latitud,
|
||||
required this.longitud,
|
||||
});
|
||||
|
||||
String get direccionCompleta => '$colonia, $calle $numero';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nombre': nombre,
|
||||
'colonia': colonia,
|
||||
'calle': calle,
|
||||
'numero': numero,
|
||||
'latitud': latitud,
|
||||
'longitud': longitud,
|
||||
};
|
||||
|
||||
factory Domicilio.fromJson(Map<String, dynamic> json) {
|
||||
return Domicilio(
|
||||
id: json['id'],
|
||||
nombre: json['nombre'],
|
||||
colonia: json['colonia'],
|
||||
calle: json['calle'],
|
||||
numero: json['numero'],
|
||||
latitud: json['latitud'].toDouble(),
|
||||
longitud: json['longitud'].toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
static String encode(List<Domicilio> domicilios) {
|
||||
return json.encode(domicilios.map((d) => d.toJson()).toList());
|
||||
}
|
||||
|
||||
static List<Domicilio> decode(String domiciliosString) {
|
||||
final List<dynamic> data = json.decode(domiciliosString);
|
||||
return data.map((item) => Domicilio.fromJson(item)).toList();
|
||||
}
|
||||
}
|
||||
72
lib/src/services/geolocation_service.dart
Normal file
72
lib/src/services/geolocation_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/services/geolocation_service.dart
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class SimplePosition {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final DateTime timestamp;
|
||||
|
||||
SimplePosition({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
class GeolocationService {
|
||||
static Future<bool> isLocationServiceEnabled() async {
|
||||
return await Geolocator.isLocationServiceEnabled();
|
||||
}
|
||||
|
||||
static Future<LocationPermission> checkPermission() async {
|
||||
return await Geolocator.checkPermission();
|
||||
}
|
||||
|
||||
static Future<LocationPermission> requestPermission() async {
|
||||
return await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
static Future<bool> hasPermission() async {
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
return permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
}
|
||||
|
||||
static Future<SimplePosition?> getCurrentLocation() async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return null;
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) return null;
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) return null;
|
||||
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.low,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
return SimplePosition(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
timestamp: position.timestamp,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SimplePosition?> getCurrentLocationWithRetry({int maxRetries = 2}) async {
|
||||
for (int i = 0; i < maxRetries; i++) {
|
||||
final position = await getCurrentLocation();
|
||||
if (position != null) return position;
|
||||
if (i < maxRetries - 1) await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
239
lib/src/views/configuracion.dart
Normal file
239
lib/src/views/configuracion.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
// configuracion.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'rutas.dart';
|
||||
|
||||
class ConfiguracionView extends StatefulWidget {
|
||||
const ConfiguracionView({super.key});
|
||||
|
||||
@override
|
||||
State<ConfiguracionView> createState() => _ConfiguracionViewState();
|
||||
}
|
||||
|
||||
class _ConfiguracionViewState extends State<ConfiguracionView> {
|
||||
String selectedOption = '7 días'; // Valor por defecto
|
||||
bool _isLoading = true;
|
||||
|
||||
// Opciones del combobox
|
||||
final List<String> opciones = [
|
||||
'Cada día',
|
||||
'Cada 3 días',
|
||||
'Cada semana',
|
||||
'Cada quincena',
|
||||
];
|
||||
|
||||
// Mapa para mostrar valores más amigables
|
||||
final Map<String, String> opcionesMap = {
|
||||
'Cada día': '1 día',
|
||||
'Cada 3 días': '3 días',
|
||||
'Cada semana': '7 días',
|
||||
'Cada quincena': '15 días',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarPreferencia();
|
||||
}
|
||||
|
||||
// Cargar la preferencia guardada
|
||||
Future<void> _cargarPreferencia() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? savedOption = prefs.getString('notificacion_frecuencia');
|
||||
|
||||
setState(() {
|
||||
if (savedOption != null && opciones.contains(savedOption)) {
|
||||
selectedOption = savedOption;
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error al cargar preferencia: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar la preferencia
|
||||
Future<void> _guardarPreferencia(String value) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notificacion_frecuencia', value);
|
||||
|
||||
// Mostrar mensaje de confirmación
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Notificaciones: $value'),
|
||||
backgroundColor: colorAzul,
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error al guardar preferencia: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Mostrar diálogo con opciones
|
||||
void _mostrarSelector() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Frecuencia de notificaciones',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorAzul,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
...opciones.map((opcion) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
selectedOption == opcion ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
color: colorAzul,
|
||||
),
|
||||
title: Text(
|
||||
opcion,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: selectedOption == opcion ? FontWeight.bold : FontWeight.normal,
|
||||
color: selectedOption == opcion ? colorAzul : Colors.black87,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
opcionesMap[opcion]!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedOption = opcion;
|
||||
});
|
||||
_guardarPreferencia(opcion);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// AppBar personalizado
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorAzul,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Configuración',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// Contenido
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: colorAzul,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Selector de notificaciones
|
||||
GestureDetector(
|
||||
onTap: _mostrarSelector,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black, width: 4),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_active_outlined, size: 60),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
selectedOption,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.keyboard_arrow_down, size: 50),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Información adicional
|
||||
Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: colorAzul.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colorAzul, size: 30),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Recibirás notificaciones cada ${opcionesMap[selectedOption]}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorAzul,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
379
lib/src/views/domicilios.dart
Normal file
379
lib/src/views/domicilios.dart
Normal file
@@ -0,0 +1,379 @@
|
||||
// domicilios.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'rutas.dart';
|
||||
import '../models/domicilio_model.dart';
|
||||
import '../services/geolocation_service.dart';
|
||||
|
||||
class DomiciliosView extends StatefulWidget {
|
||||
const DomiciliosView({super.key});
|
||||
|
||||
@override
|
||||
State<DomiciliosView> createState() => _DomiciliosViewState();
|
||||
}
|
||||
|
||||
class _DomiciliosViewState extends State<DomiciliosView> {
|
||||
List<Domicilio> domicilios = [];
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingLocation = false;
|
||||
|
||||
final TextEditingController nombreController = TextEditingController();
|
||||
final TextEditingController coloniaController = TextEditingController();
|
||||
final TextEditingController calleController = TextEditingController();
|
||||
final TextEditingController numeroController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarDomicilios();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nombreController.dispose();
|
||||
coloniaController.dispose();
|
||||
calleController.dispose();
|
||||
numeroController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _cargarDomicilios() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? domiciliosString = prefs.getString('domicilios');
|
||||
|
||||
setState(() {
|
||||
if (domiciliosString != null && domiciliosString.isNotEmpty) {
|
||||
domicilios = Domicilio.decode(domiciliosString);
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _guardarDomicilios() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String domiciliosString = Domicilio.encode(domicilios);
|
||||
await prefs.setString('domicilios', domiciliosString);
|
||||
} catch (e) {
|
||||
print('Error al guardar: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _showLocationPermissionDialog() async {
|
||||
if (await GeolocationService.hasPermission()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Permiso de ubicación'),
|
||||
content: const Text(
|
||||
'Necesitamos acceder a tu ubicación para asignar tu domicilio a la ruta correcta.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Aceptar', style: TextStyle(color: colorAzul)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
LocationPermission newPermission = await GeolocationService.requestPermission();
|
||||
return newPermission == LocationPermission.always ||
|
||||
newPermission == LocationPermission.whileInUse;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _obtenerUbicacionYAgregar() async {
|
||||
setState(() {
|
||||
_isLoadingLocation = true;
|
||||
});
|
||||
|
||||
final serviceEnabled = await GeolocationService.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
setState(() {
|
||||
_isLoadingLocation = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Activa el GPS para agregar un domicilio'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission = await _showLocationPermissionDialog();
|
||||
if (!hasPermission) {
|
||||
setState(() {
|
||||
_isLoadingLocation = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Necesitamos tu ubicación'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final simplePosition = await GeolocationService.getCurrentLocation();
|
||||
|
||||
setState(() {
|
||||
_isLoadingLocation = false;
|
||||
});
|
||||
|
||||
if (simplePosition == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No se pudo obtener tu ubicación.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_mostrarDialogoAgregarConUbicacion(simplePosition.latitude, simplePosition.longitude);
|
||||
}
|
||||
|
||||
void _mostrarDialogoAgregarConUbicacion(double lat, double lng) {
|
||||
nombreController.clear();
|
||||
coloniaController.clear();
|
||||
calleController.clear();
|
||||
numeroController.clear();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Añadir domicilio', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: colorAzul)),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.location_on, color: Colors.green[700], size: 24),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('📍 Ubicación obtenida', style: TextStyle(fontSize: 12, color: Colors.green[700])),
|
||||
Text('Lat: ${lat.toStringAsFixed(6)}'),
|
||||
Text('Lng: ${lng.toStringAsFixed(6)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
_buildCampoTexto(controller: nombreController, hint: 'Nombre del domicilio', icon: Icons.home_outlined),
|
||||
const SizedBox(height: 15),
|
||||
_buildCampoTexto(controller: coloniaController, hint: 'Colonia', icon: Icons.location_city_outlined),
|
||||
const SizedBox(height: 15),
|
||||
_buildCampoTexto(controller: calleController, hint: 'Calle', icon: Icons.streetview),
|
||||
const SizedBox(height: 15),
|
||||
_buildCampoTexto(controller: numeroController, hint: 'Número', icon: Icons.numbers, keyboardType: TextInputType.number),
|
||||
const SizedBox(height: 25),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(side: BorderSide(color: colorAzul, width: 2), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancelar', style: TextStyle(color: colorAzul)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: colorAzul, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))),
|
||||
onPressed: () => _agregarDomicilio(latitud: lat, longitud: lng),
|
||||
child: const Text('Agregar', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCampoTexto({
|
||||
required TextEditingController controller,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
prefixIcon: Icon(icon, color: colorAzul),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: BorderSide(color: colorAzul.withOpacity(0.5))),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15), borderSide: const BorderSide(color: colorAzul, width: 2)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _agregarDomicilio({required double latitud, required double longitud}) async {
|
||||
if (nombreController.text.trim().isEmpty ||
|
||||
coloniaController.text.trim().isEmpty ||
|
||||
calleController.text.trim().isEmpty ||
|
||||
numeroController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Llena todos los campos'), backgroundColor: Colors.red),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final nuevoDomicilio = Domicilio(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
nombre: nombreController.text.trim(),
|
||||
colonia: coloniaController.text.trim(),
|
||||
calle: calleController.text.trim(),
|
||||
numero: numeroController.text.trim(),
|
||||
latitud: latitud,
|
||||
longitud: longitud,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
domicilios.add(nuevoDomicilio);
|
||||
});
|
||||
|
||||
await _guardarDomicilios();
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Domicilio agregado'), backgroundColor: colorAzul),
|
||||
);
|
||||
}
|
||||
|
||||
void _eliminarDomicilio(int index) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Eliminar domicilio'),
|
||||
content: Text('¿Eliminar "${domicilios[index].nombre}"?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancelar')),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
setState(() => domicilios.removeAt(index));
|
||||
await _guardarDomicilios();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Eliminar', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorAzul,
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
|
||||
),
|
||||
child: const Text('Domicilios', style: TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: colorAzul))
|
||||
: domicilios.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.home_outlined, size: 100, color: Colors.grey.withOpacity(0.5)),
|
||||
const SizedBox(height: 20),
|
||||
Text('No hay domicilios', style: TextStyle(fontSize: 18, color: Colors.grey.withOpacity(0.7))),
|
||||
const SizedBox(height: 10),
|
||||
Text('Toca el botón + para agregar', style: TextStyle(fontSize: 14, color: Colors.grey.withOpacity(0.5))),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: domicilios.length,
|
||||
itemBuilder: (context, index) {
|
||||
final d = domicilios[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.black, width: 4), borderRadius: BorderRadius.circular(25)),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.home_outlined, size: 60),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(d.nombre, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
Text(d.direccionCompleta, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
Text('📍 ${d.latitud.toStringAsFixed(4)}, ${d.longitud.toStringAsFixed(4)}', style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: () => _eliminarDomicilio(index), icon: const Icon(Icons.delete_outline, size: 40), color: Colors.red),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: _isLoadingLocation
|
||||
? Container(width: double.infinity, height: 100, decoration: BoxDecoration(color: colorAzul, borderRadius: BorderRadius.circular(20)), child: const Center(child: CircularProgressIndicator(color: Colors.white)))
|
||||
: GestureDetector(
|
||||
onTap: _obtenerUbicacionYAgregar,
|
||||
child: Container(width: double.infinity, height: 100, decoration: BoxDecoration(color: colorAzul, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.add, color: Colors.white, size: 80)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/src/views/home_screen.dart
Normal file
42
lib/src/views/home_screen.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
//home_screen.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'domicilios.dart';
|
||||
import 'horarios.dart';
|
||||
import 'configuracion.dart';
|
||||
import 'rutas.dart';
|
||||
import 'nav_bar.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _currentIndex = 0; // 0: Domicilios, 1: Rutas, 2: Configuración
|
||||
|
||||
final List<Widget> _paginas = const [
|
||||
DomiciliosView(),
|
||||
HorariosView(),
|
||||
ConfiguracionView(),
|
||||
];
|
||||
|
||||
void _onNavBarTap(int index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: _paginas[_currentIndex],
|
||||
bottomNavigationBar: CustomNavBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: _onNavBarTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
224
lib/src/views/horarios.dart
Normal file
224
lib/src/views/horarios.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'rutas.dart';
|
||||
import '../models/domicilio_model.dart';
|
||||
import 'mapa_expandible.dart';
|
||||
|
||||
class HorariosView extends StatefulWidget {
|
||||
const HorariosView({super.key});
|
||||
|
||||
@override
|
||||
State<HorariosView> createState() => _HorariosViewState();
|
||||
}
|
||||
|
||||
class _HorariosViewState extends State<HorariosView> {
|
||||
List<Domicilio> domicilios = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cargarDomicilios();
|
||||
}
|
||||
|
||||
Future<void> _cargarDomicilios() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? domiciliosString = prefs.getString('domicilios');
|
||||
|
||||
setState(() {
|
||||
if (domiciliosString != null && domiciliosString.isNotEmpty) {
|
||||
domicilios = Domicilio.decode(domiciliosString);
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// AppBar personalizado
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: colorAzul,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Mis Rutas',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// Lista de domicilios
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: colorAzul))
|
||||
: domicilios.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 100,
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'No hay domicilios agregados',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Agrega domicilios desde la pestaña Domicilios',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: domicilios.length,
|
||||
itemBuilder: (context, index) {
|
||||
final domicilio = domicilios[index];
|
||||
return _buildDomicilioCard(domicilio, index);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDomicilioCard(Domicilio domicilio, int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black, width: 4),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Contenido principal del domicilio
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Column(
|
||||
children: [
|
||||
// Info principal
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.home_outlined, size: 60),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
domicilio.nombre,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
domicilio.direccionCompleta,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'📍 ${domicilio.latitud.toStringAsFixed(4)}, ${domicilio.longitud.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Fila de horario y botón de mapa
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Horario (placeholder para API del backend)
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorAzul.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, color: colorAzul, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Horario: ${_obtenerHorarioEstimado(index)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorAzul,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Botón/flecha para mapa desplegable
|
||||
MapaExpandible(
|
||||
latitud: domicilio.latitud,
|
||||
longitud: domicilio.longitud,
|
||||
nombreDomicilio: domicilio.nombre,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Horario estimado (placeholder para cuando conectes con el backend)
|
||||
String _obtenerHorarioEstimado(int index) {
|
||||
// Esto es solo un placeholder - aquí irá la llamada a tu API
|
||||
final horarios = ['8:00 AM - 9:00 AM', '9:30 AM - 10:30 AM', '11:00 AM - 12:00 PM', '1:00 PM - 2:00 PM', '3:00 PM - 4:00 PM', '5:00 PM - 6:00 PM'];
|
||||
return horarios[index % horarios.length];
|
||||
}
|
||||
}
|
||||
111
lib/src/views/login.dart
Normal file
111
lib/src/views/login.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
// login.dart - cambiar MainScreen por HomeScreen
|
||||
import 'package:flutter/material.dart';
|
||||
import 'rutas.dart';
|
||||
import 'home_screen.dart'; // ← Importar HomeScreen
|
||||
|
||||
class LoginView extends StatelessWidget {
|
||||
const LoginView({super.key});
|
||||
|
||||
final Color colorAzul = const Color(0xFF0F0D38);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorAzul,
|
||||
title: const Text('Iniciar Sesión',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 28)),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white, size: 30),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(30.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInput(Icons.email_outlined, 'Correo electrónico', obscureText: false),
|
||||
const SizedBox(height: 20),
|
||||
_buildInput(Icons.lock_outline, 'Contraseña', obscureText: true),
|
||||
const SizedBox(height: 50),
|
||||
Column(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorAzul,
|
||||
minimumSize: const Size(double.infinity, 55),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: () {
|
||||
// ✅ Navegar a HomeScreen
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'Iniciar Sesión',
|
||||
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 55),
|
||||
side: BorderSide(color: colorAzul, width: 2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
'Crear Cuenta',
|
||||
style: TextStyle(color: colorAzul, fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInput(IconData icon, String hint, {bool obscureText = false}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black, width: 2),
|
||||
),
|
||||
child: Icon(icon, size: 40, color: Colors.black),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
obscureText: obscureText,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: colorAzul.withOpacity(0.5), fontWeight: FontWeight.bold, fontSize: 22),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: BorderSide(color: colorAzul, width: 4),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderSide: BorderSide(color: colorAzul, width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/src/views/main_screen.dart
Normal file
70
lib/src/views/main_screen.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'rutas.dart';
|
||||
import 'domicilios.dart';
|
||||
import 'horarios.dart';
|
||||
import 'configuracion.dart';
|
||||
import 'nav_bar.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
late PageController _pageController;
|
||||
int _currentIndex = 1; // Comenzar en Horarios (Rutas)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
void _onNavBarTap(int index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: colorAzul, // Mismo color de fondo para evitar flash blanco
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(), // Efecto de rebote al deslizar
|
||||
children: const [
|
||||
DomiciliosView(),
|
||||
HorariosView(),
|
||||
ConfiguracionView(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: CustomNavBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: _onNavBarTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/src/views/mapa_expandible.dart
Normal file
138
lib/src/views/mapa_expandible.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
// src/views/mapa_expandible.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'rutas.dart';
|
||||
|
||||
class MapaExpandible extends StatefulWidget {
|
||||
final double latitud;
|
||||
final double longitud;
|
||||
final String nombreDomicilio;
|
||||
|
||||
const MapaExpandible({
|
||||
super.key,
|
||||
required this.latitud,
|
||||
required this.longitud,
|
||||
required this.nombreDomicilio,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MapaExpandible> createState() => _MapaExpandibleState();
|
||||
}
|
||||
|
||||
class _MapaExpandibleState extends State<MapaExpandible> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
bool get _isValidLatLng {
|
||||
final lat = widget.latitud;
|
||||
final lng = widget.longitud;
|
||||
return lat.isFinite && lng.isFinite && lat != 0 && lng != 0;
|
||||
}
|
||||
|
||||
Future<void> _abrirGoogleMaps() async {
|
||||
final url = 'https://www.google.com/maps?q=${widget.latitud},${widget.longitud}&z=15';
|
||||
try {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No se pudo abrir el mapa'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isValidLatLng) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text('Ubicación no disponible', style: TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
color: colorAzul,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_isExpanded ? 'Ocultar mapa' : 'Ver mapa',
|
||||
style: TextStyle(color: colorAzul, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isExpanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: GestureDetector(
|
||||
onTap: _abrirGoogleMaps,
|
||||
child: Container(
|
||||
width: 200, // Ancho fijo en lugar de infinity
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: colorAzul.withOpacity(0.3), width: 1),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.map, size: 40, color: colorAzul),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ver en Google Maps',
|
||||
style: TextStyle(color: colorAzul, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.latitud.toStringAsFixed(4)}, ${widget.longitud.toStringAsFixed(4)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: colorAzul,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Tocar para abrir',
|
||||
style: TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/src/views/nav_bar.dart
Normal file
86
lib/src/views/nav_bar.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'rutas.dart';
|
||||
|
||||
class CustomNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final Function(int) onTap;
|
||||
|
||||
const CustomNavBar({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 90,
|
||||
color: colorAzul,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildNavButton(
|
||||
index: 0,
|
||||
currentIndex: currentIndex,
|
||||
iconNormal: Icons.home_outlined,
|
||||
iconActive: Icons.home,
|
||||
label: 'Domicilios',
|
||||
onTap: onTap,
|
||||
),
|
||||
_buildNavButton(
|
||||
index: 1,
|
||||
currentIndex: currentIndex,
|
||||
iconNormal: Icons.alt_route_rounded,
|
||||
iconActive: Icons.alt_route_rounded,
|
||||
label: 'Rutas',
|
||||
onTap: onTap,
|
||||
),
|
||||
_buildNavButton(
|
||||
index: 2,
|
||||
currentIndex: currentIndex,
|
||||
iconNormal: Icons.settings_outlined,
|
||||
iconActive: Icons.settings,
|
||||
label: 'Configuración',
|
||||
onTap: onTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavButton({
|
||||
required int index,
|
||||
required int currentIndex,
|
||||
required IconData iconNormal,
|
||||
required IconData iconActive,
|
||||
required String label,
|
||||
required Function(int) onTap,
|
||||
}) {
|
||||
final bool isActive = currentIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(index),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? iconActive : iconNormal,
|
||||
color: Colors.white,
|
||||
size: isActive ? 55 : 35,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isActive ? 14 : 12,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
4
lib/src/views/rutas.dart
Normal file
4
lib/src/views/rutas.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const Color colorAzul = Color(0xFF0F0D38);
|
||||
const Color colorAzulClaro = Color(0xFF2A2A5E);
|
||||
@@ -1,78 +0,0 @@
|
||||
// lib/core/theme/app_theme.dart
|
||||
// Persona C importa este archivo también.
|
||||
// Un solo lugar para colores, tipografía y estilos.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// ── Paleta de categorías ─────────────────────────────────────────
|
||||
static const organicosColor = Color(0xFF4CAF50);
|
||||
static const reciclabesColor = Color(0xFF2196F3);
|
||||
static const sanitariosColor = Color(0xFFFF5722);
|
||||
static const especialesColor = Color(0xFFFF9800);
|
||||
|
||||
// ── Paleta general ───────────────────────────────────────────────
|
||||
static const primaryColor = Color(0xFF1B5E20); // verde oscuro
|
||||
static const secondaryColor = Color(0xFF2E7D32);
|
||||
static const backgroundColor = Color(0xFFF5F5F5);
|
||||
static const surfaceColor = Color(0xFFFFFFFF);
|
||||
static const errorColor = Color(0xFFD32F2F);
|
||||
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
surface: surfaceColor,
|
||||
error: errorColor,
|
||||
),
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: surfaceColor,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceColor,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ── Colores por id de categoría ──────────────────────────────────
|
||||
static Color colorDeCategoriaId(String id) {
|
||||
return switch (id) {
|
||||
'organicos' => organicosColor,
|
||||
'reciclables' => reciclabesColor,
|
||||
'sanitarios' => sanitariosColor,
|
||||
'especiales' => especialesColor,
|
||||
_ => primaryColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) gtk_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
|
||||
gtk_plugin_register_with_registrar(gtk_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
gtk
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
jni
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import app_links
|
||||
import geolocator_apple
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
807
pubspec.lock
807
pubspec.lock
@@ -1,6 +1,46 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
app_links:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +89,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
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:
|
||||
@@ -49,6 +121,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_jsonwebtoken:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_jsonwebtoken
|
||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.2"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +153,30 @@ packages:
|
||||
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"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -70,6 +190,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_map:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
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_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -83,6 +219,203 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
functions_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: functions_client
|
||||
sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
geolocator:
|
||||
dependency: "direct dev"
|
||||
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 dev"
|
||||
description:
|
||||
name: google_maps_flutter
|
||||
sha256: "9b0d6dab3de6955837575dc371dd772fcb5d0a90f6a4954e8c066472f9938550"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.2"
|
||||
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: "direct dev"
|
||||
description:
|
||||
name: google_maps_flutter_web
|
||||
sha256: d416602944e1859f3cbbaa53e34785c223fa0a11eddb34a913c964c5cbb5d8cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.14+3"
|
||||
gotrue:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gotrue
|
||||
sha256: "7a4172601553e61716f5c3dd243aa3297e13308e07eb85b7853c941ba585dcf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.20.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
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: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
jwt_decode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jwt_decode
|
||||
sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
latlong2:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: latlong2
|
||||
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -115,6 +448,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -139,6 +496,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -147,6 +536,182 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
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 dev"
|
||||
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"
|
||||
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"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
polylabel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: polylabel
|
||||
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
postgrest:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: postgrest
|
||||
sha256: "9d61b3d4a88fcf9424d400127c54d49ed1b56ec30838fc0a33a64f31d4e694cc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
realtime_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: realtime_client
|
||||
sha256: "7dfccf372d2f55aacfeefb6186f65a06f3ffae383fe042dbeef9d85d33487576"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.3"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
retry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: retry
|
||||
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -155,6 +720,78 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
sanitize_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sanitize_html
|
||||
sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
shared_preferences:
|
||||
dependency: "direct dev"
|
||||
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
|
||||
@@ -184,6 +821,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
storage_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: storage_client
|
||||
sha256: "4801e8ca219a35e51cbb30589aba5306667ae8935b792504595a45273cef0b18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -192,6 +837,14 @@ packages:
|
||||
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:
|
||||
@@ -200,6 +853,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
supabase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: supabase
|
||||
sha256: "40e5a8833c8834e140ef53b60a6181849667eba9ca125acb7f8e24c6a769d418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.6"
|
||||
supabase_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: supabase_flutter
|
||||
sha256: c02ce58abcaf86cb8055ad40bfd98bbf5b93fed3b5b56b8220d88ed03842818b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.4"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -216,6 +885,94 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
url_launcher:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -232,6 +989,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
yet_another_json_isolate:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yet_another_json_isolate
|
||||
sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
23
pubspec.yaml
23
pubspec.yaml
@@ -34,12 +34,25 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_riverpod: ^2.4.0
|
||||
supabase_flutter: ^2.5.0
|
||||
flutter_riverpod: ^2.6.1
|
||||
dio: ^5.3.1
|
||||
web_socket_channel: ^2.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
shared_preferences: ^2.2.2
|
||||
geolocator: ^11.0.0
|
||||
permission_handler: ^11.3.0
|
||||
|
||||
flutter_map: ^6.1.0 # ← Agregar para el mapa
|
||||
latlong2: ^0.9.0
|
||||
google_maps_flutter: ^2.5.0
|
||||
google_maps_flutter_web: ^0.5.0
|
||||
url_launcher: ^6.2.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
@@ -58,10 +71,10 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
# Assets
|
||||
assets:
|
||||
- assets/recycling_guide.json
|
||||
- assets/rutas.json
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
13
railway.json
Normal file
13
railway.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "dockerfile"
|
||||
},
|
||||
"deploy": {
|
||||
"numReplicas": 1,
|
||||
"startCommand": "python -m uvicorn app.main:app --host 0.0.0.0 --port $PORT"
|
||||
},
|
||||
"plugins": [
|
||||
"postgresql"
|
||||
]
|
||||
}
|
||||
30
run_backend.sh
Normal file
30
run_backend.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
cd server
|
||||
|
||||
echo -e "${YELLOW}Checking Python...${NC}"
|
||||
python --version
|
||||
|
||||
echo -e "${YELLOW}Creating venv...${NC}"
|
||||
if [ ! -d "venv" ]; then
|
||||
python -m venv venv
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Activating venv...${NC}"
|
||||
source venv/Scripts/activate 2>/dev/null || source venv/bin/activate
|
||||
|
||||
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
echo -e "${GREEN}✓ Dependencies installed${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Starting backend on http://localhost:8000${NC}"
|
||||
echo -e "${YELLOW}Docs available at http://localhost:8000/docs${NC}"
|
||||
echo ""
|
||||
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
21
server/.dockerignore
Normal file
21
server/.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
logs/*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.vscode
|
||||
.idea
|
||||
venv
|
||||
.venv
|
||||
9
server/.env
Normal file
9
server/.env
Normal file
@@ -0,0 +1,9 @@
|
||||
SECRET_KEY=mi-clave-secreta-super-segura-cambiar-en-produccion-123456
|
||||
DEBUG=true
|
||||
DATABASE_PATH=/data/basura.db
|
||||
SIM_TICK_SECONDS=10
|
||||
SIM_ETA_ALERT_MINUTES=10
|
||||
SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co
|
||||
SUPABASE_ANON_KEY=sb_publishable_E7S9GSVk3nOw93DgA_nPIA_SP-hWS-H
|
||||
SUPABASE_SERVICE_KEY=sb_secret_2y3a_9qD5nRtZl-41CY-jw_LA-smvxC
|
||||
CACHE_ENABLED=false
|
||||
11
server/.env.example
Normal file
11
server/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||
DEBUG=true
|
||||
|
||||
# Supabase PostgreSQL
|
||||
SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co
|
||||
SUPABASE_ANON_KEY=sb_publishable_FQR0WXK6joM043Qve9gz3A_pJfAH...
|
||||
SUPABASE_SERVICE_KEY=sb_secret_2y3a_...
|
||||
|
||||
# Simulador
|
||||
SIM_TICK_SECONDS=10
|
||||
SIM_ETA_ALERT_MINUTES=10
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
3
server/app/api/__init__.py
Normal file
3
server/app/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.routes.eta_router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
3
server/app/api/routes/__init__.py
Normal file
3
server/app/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.routes.eta_router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
114
server/app/api/routes/addresses_router.py
Normal file
114
server/app/api/routes/addresses_router.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AddressCreate(BaseModel):
|
||||
lat: float
|
||||
lng: float
|
||||
alias: Optional[str] = None
|
||||
address_text: str
|
||||
|
||||
|
||||
@router.post("/", summary="Crear nueva dirección")
|
||||
async def create_address(
|
||||
address: AddressCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Crear dirección para usuario autenticado."""
|
||||
# Determinar ruta basada en ubicación (simplificado - usar PostGIS en producción)
|
||||
route_id = "RUTA-01" # Mock: calcular basado en lat/lng
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
result = db.table("addresses").insert({
|
||||
"user_id": current_user["id"],
|
||||
"alias": address.alias,
|
||||
"lat": address.lat,
|
||||
"lng": address.lng,
|
||||
"route_id": route_id,
|
||||
}).execute()
|
||||
|
||||
new_address = result.data[0]
|
||||
return {
|
||||
"id": new_address["id"],
|
||||
"user_id": new_address["user_id"],
|
||||
"alias": new_address["alias"],
|
||||
"lat": new_address["lat"],
|
||||
"lng": new_address["lng"],
|
||||
"route_id": new_address["route_id"],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/", summary="Obtener direcciones del usuario")
|
||||
async def get_addresses(current_user: dict = Depends(get_current_user)):
|
||||
"""Obtener todas las direcciones del usuario autenticado."""
|
||||
db = get_db()
|
||||
try:
|
||||
result = db.table("addresses").select(
|
||||
"id, alias, lat, lng, route_id"
|
||||
).eq("user_id", current_user["id"]).execute()
|
||||
|
||||
return result.data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{address_id}", summary="Obtener dirección específica")
|
||||
async def get_address(
|
||||
address_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Obtener detalle de una dirección específica (solo del usuario)."""
|
||||
db = get_db()
|
||||
try:
|
||||
result = db.table("addresses").select("*").eq("id", address_id).execute()
|
||||
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=404, detail="Address not found")
|
||||
|
||||
address = result.data[0]
|
||||
|
||||
# RBAC: verificar que la dirección pertenece al usuario
|
||||
if address["user_id"] != current_user["id"]:
|
||||
raise HTTPException(status_code=403, detail="No autorizado")
|
||||
|
||||
return address
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{address_id}", summary="Eliminar dirección")
|
||||
async def delete_address(
|
||||
address_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Eliminar dirección del usuario."""
|
||||
db = get_db()
|
||||
try:
|
||||
# Verificar RBAC primero
|
||||
result = db.table("addresses").select("user_id").eq("id", address_id).execute()
|
||||
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=404, detail="Address not found")
|
||||
|
||||
if result.data[0]["user_id"] != current_user["id"]:
|
||||
raise HTTPException(status_code=403, detail="No autorizado")
|
||||
|
||||
# Eliminar
|
||||
db.table("addresses").delete().eq("id", address_id).execute()
|
||||
|
||||
return {"ok": True, "message": "Address deleted"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
120
server/app/api/routes/auth_router.py
Normal file
120
server/app/api/routes/auth_router.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: EmailStr
|
||||
phone: str | None = None
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_token(user_id: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": user_id, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(user: UserRegister):
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
# Verificar si email existe
|
||||
existing = db.table("users").select("id").eq("email", user.email).execute()
|
||||
if existing.data:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Crear usuario
|
||||
password_hash = hash_password(user.password)
|
||||
user_data = {
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
new_user = db.table("users").insert(user_data).execute()
|
||||
user_id = new_user.data[0]["id"]
|
||||
|
||||
# Crear preferencias por defecto
|
||||
db.table("notification_preferences").insert({
|
||||
"user_id": user_id,
|
||||
"notify_proximity": True,
|
||||
"notify_breakdown": True,
|
||||
"notify_delay": True,
|
||||
"notify_route_start": True,
|
||||
}).execute()
|
||||
|
||||
token = create_token(user_id)
|
||||
return TokenResponse(access_token=token, user_id=user_id)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(user: UserLogin):
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
# Buscar usuario por email
|
||||
result = db.table("users").select("id, password_hash").eq("email", user.email).execute()
|
||||
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
db_user = result.data[0]
|
||||
|
||||
# Verificar password
|
||||
if not verify_password(user.password, db_user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
user_id = db_user["id"]
|
||||
token = create_token(user_id)
|
||||
return TokenResponse(access_token=token, user_id=user_id)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(token: str):
|
||||
"""Verifica si el JWT es válido."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"valid": True, "user_id": user_id}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
155
server/app/api/routes/eta_router.py
Normal file
155
server/app/api/routes/eta_router.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Endpoints del Módulo B con caching
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.data.repositories.ruta_repository import SupabaseRutaRepository
|
||||
from app.services.simulador import obtener_simulador
|
||||
from app.services.ws_manager import ws_manager
|
||||
from app.core.cache import cached, cache_client, invalidate_route_cache
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.db.database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _repo() -> SupabaseRutaRepository:
|
||||
return SupabaseRutaRepository()
|
||||
|
||||
|
||||
# ── GET /eta/{address_id} con caché ─────────────────────────────────────────
|
||||
|
||||
@router.get("/eta/{address_id}", summary="Ventana ETA para un domicilio")
|
||||
@cached(prefix="eta", ttl=30)
|
||||
async def get_eta(
|
||||
address_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Devuelve ETA + ventana horaria solo para el domicilio solicitado.
|
||||
Cacheado por 30 segundos para evitar consultas repetidas.
|
||||
"""
|
||||
# Verificar que el domicilio pertenece al usuario (RBAC)
|
||||
db = get_db()
|
||||
try:
|
||||
result = db.table("addresses").select("user_id").eq("id", address_id).execute()
|
||||
if not result.data or result.data[0]["user_id"] != current_user["id"]:
|
||||
raise HTTPException(status_code=403, detail="No autorizado")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
resultado = _repo().calcular_eta(address_id)
|
||||
if not resultado:
|
||||
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
|
||||
|
||||
return {
|
||||
"address_id": resultado.address_id,
|
||||
"route_id": resultado.route_id,
|
||||
"status": resultado.status,
|
||||
"eta_minutos": resultado.eta_minutos,
|
||||
"ventana": {
|
||||
"inicio": resultado.ventana_inicio,
|
||||
"fin": resultado.ventana_fin,
|
||||
},
|
||||
"mensaje": resultado.mensaje,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
|
||||
# ── WS /ws/{address_id} ───────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/{address_id}")
|
||||
async def websocket_address(websocket: WebSocket, address_id: int):
|
||||
"""WebSocket para recibir notificaciones en tiempo real"""
|
||||
zona_key = str(address_id)
|
||||
await ws_manager.conectar(websocket, zona_key)
|
||||
logger.info(f"[WS] Cliente conectado — address_id={address_id}")
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # mantener vivo
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.desconectar(websocket, zona_key)
|
||||
logger.info(f"[WS] Cliente desconectado — address_id={address_id}")
|
||||
|
||||
|
||||
# ── POST /alerts/breakdown ────────────────────────────────────────────
|
||||
|
||||
class BreakdownPayload(BaseModel):
|
||||
route_id: str
|
||||
mensaje: Optional[str] = "El camión reportó una falla mecánica."
|
||||
|
||||
|
||||
@router.post("/alerts/breakdown", summary="Reportar avería de camión")
|
||||
async def reportar_averia(payload: BreakdownPayload):
|
||||
"""Endpoint para reportar avería - invalida caché automáticamente"""
|
||||
sim = obtener_simulador(payload.route_id)
|
||||
await sim.forzar_averia(payload.mensaje)
|
||||
|
||||
# Invalidar caché de esta ruta
|
||||
await invalidate_route_cache(payload.route_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"route_id": payload.route_id,
|
||||
"mensaje": "Avería registrada y usuarios notificados",
|
||||
}
|
||||
|
||||
|
||||
# ── Admin / Demo ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/admin/route/{route_id}/start", summary="Iniciar simulación")
|
||||
async def iniciar_ruta(route_id: str):
|
||||
"""Iniciar el simulador de camión"""
|
||||
obtener_simulador(route_id).iniciar()
|
||||
await invalidate_route_cache(route_id)
|
||||
return {"ok": True, "mensaje": f"Simulador {route_id} iniciado"}
|
||||
|
||||
|
||||
@router.post("/admin/route/{route_id}/delay", summary="Forzar retraso")
|
||||
async def forzar_retraso(route_id: str, mensaje: str = "El camión reportó un retraso."):
|
||||
"""Forzar retraso en la ruta"""
|
||||
await obtener_simulador(route_id).forzar_retraso(mensaje)
|
||||
await invalidate_route_cache(route_id)
|
||||
return {"ok": True, "mensaje": "Retraso notificado"}
|
||||
|
||||
|
||||
@router.get("/admin/route/{route_id}/status", summary="Estado interno del camión")
|
||||
async def estado_ruta(route_id: str):
|
||||
"""Obtener estado actual del camión"""
|
||||
repo = _repo()
|
||||
ts = repo.obtener_truck_status(route_id)
|
||||
if not ts:
|
||||
raise HTTPException(status_code=404, detail="Ruta no encontrada")
|
||||
return {
|
||||
"route_id": ts.route_id,
|
||||
"current_position_id": ts.current_position_id,
|
||||
"status": ts.status.value,
|
||||
"last_update": ts.last_update.isoformat(),
|
||||
"ws_clientes_activos": ws_manager.zonas_activas(),
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint para limpiar caché (admin) ──────────────────────────────
|
||||
|
||||
@router.post("/admin/cache/clear", summary="Limpiar toda la caché")
|
||||
async def clear_cache():
|
||||
"""Limpiar caché de Redis y memoria"""
|
||||
await cache_client.clear_all()
|
||||
return {"ok": True, "mensaje": "Caché limpiada"}
|
||||
|
||||
|
||||
@router.get("/admin/cache/stats", summary="Estadísticas de caché")
|
||||
async def cache_stats():
|
||||
"""Estadísticas del sistema de caché"""
|
||||
return {
|
||||
"enabled": cache_client.enabled,
|
||||
"redis_available": cache_client.redis_client is not None,
|
||||
"memory_cache_size": len(cache_client.memory_cache),
|
||||
}
|
||||
82
server/app/api/routes/guide_router.py
Normal file
82
server/app/api/routes/guide_router.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Guía de separación de residuos - endpoint cacheado
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.core.cache import cached
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Guía de separación (cache por 24 horas)
|
||||
RECYCLING_GUIDE = {
|
||||
"categories": [
|
||||
{
|
||||
"name": "Orgánico",
|
||||
"color": "#4CAF50",
|
||||
"icon": "leaf",
|
||||
"items": [
|
||||
"Restos de comida",
|
||||
"Cáscaras de fruta",
|
||||
"Hojas y césped",
|
||||
"Cáscaras de huevo",
|
||||
"Café y filtros de papel",
|
||||
"Servilletas de papel",
|
||||
"Restos de poda"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Reciclable",
|
||||
"color": "#2196F3",
|
||||
"icon": "recycle",
|
||||
"items": [
|
||||
"Plástico (PET, HDPE, PP)",
|
||||
"Vidrio (botellas, frascos)",
|
||||
"Papel y cartón (limpio y seco)",
|
||||
"Latas de aluminio",
|
||||
"Envases Tetra Pak",
|
||||
"Periódicos y revistas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Sanitario",
|
||||
"color": "#9C27B0",
|
||||
"icon": "medical-services",
|
||||
"items": [
|
||||
"Pañales desechables",
|
||||
"Toallas sanitarias",
|
||||
"Papel higiénico usado",
|
||||
"Algodón y gasas",
|
||||
"Cubrebocas",
|
||||
"Jeringas (en contenedor especial)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Peligroso",
|
||||
"color": "#F44336",
|
||||
"icon": "warning",
|
||||
"items": [
|
||||
"Pilas y baterías",
|
||||
"Aceite de cocina usado",
|
||||
"Pinturas y solventes",
|
||||
"Químicos de limpieza",
|
||||
"Medicamentos caducados",
|
||||
"Focos y fluorescentes",
|
||||
"Electrónicos"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
"Lava los envases reciclables antes de desecharlos",
|
||||
"No mezcles residuos peligrosos con la basura común",
|
||||
"Los residuos sanitarios deben ir en bolsa aparte",
|
||||
"El aceite de cocina debe almacenarse en botella cerrada"
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/recycling-guide")
|
||||
@cached(prefix="guide", ttl=86400) # 24 horas de caché
|
||||
async def get_recycling_guide():
|
||||
"""
|
||||
Guía de separación de residuos.
|
||||
Funciona offline en el cliente (cacheable por 24 horas).
|
||||
"""
|
||||
return RECYCLING_GUIDE
|
||||
3
server/app/core/__init__.py
Normal file
3
server/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.core.config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
198
server/app/core/cache.py
Normal file
198
server/app/core/cache.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Sistema de caching con Redis y memoria
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Optional, Any, Callable
|
||||
from datetime import datetime, timedelta
|
||||
import redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Cliente unificado de caché"""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = None
|
||||
self.memory_cache = {} # Fallback en memoria
|
||||
self.enabled = settings.cache_enabled
|
||||
|
||||
if self.enabled:
|
||||
try:
|
||||
self.redis_client = redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
db=settings.redis_db,
|
||||
password=settings.redis_password,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
# Probar conexión
|
||||
self.redis_client.ping()
|
||||
logger.info(f"✅ Redis conectado en {settings.redis_host}:{settings.redis_port}")
|
||||
except RedisError as e:
|
||||
logger.warning(f"⚠️ Redis no disponible: {e}. Usando caché en memoria.")
|
||||
self.redis_client = None
|
||||
self.enabled = False
|
||||
|
||||
def _get_key(self, prefix: str, key: str) -> str:
|
||||
"""Genera clave con prefijo"""
|
||||
return f"{prefix}:{key}"
|
||||
|
||||
def _hash_key(self, key: str) -> str:
|
||||
"""Hash de claves largas"""
|
||||
if len(key) > 100:
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
return key
|
||||
|
||||
async def get(self, prefix: str, key: str) -> Optional[Any]:
|
||||
"""Obtener valor del caché"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
# Intentar Redis primero
|
||||
if self.redis_client:
|
||||
value = self.redis_client.get(cache_key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
|
||||
# Fallback a memoria
|
||||
if cache_key in self.memory_cache:
|
||||
data, expiry = self.memory_cache[cache_key]
|
||||
if datetime.now() < expiry:
|
||||
return data
|
||||
else:
|
||||
del self.memory_cache[cache_key]
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading cache: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, prefix: str, key: str, value: Any, ttl: int = 60):
|
||||
"""Guardar en caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
|
||||
if self.redis_client:
|
||||
self.redis_client.setex(cache_key, ttl, serialized)
|
||||
|
||||
# Guardar también en memoria
|
||||
self.memory_cache[cache_key] = (value, datetime.now() + timedelta(seconds=ttl))
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing cache: {e}")
|
||||
|
||||
async def delete(self, prefix: str, key: str):
|
||||
"""Eliminar del caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
if self.redis_client:
|
||||
self.redis_client.delete(cache_key)
|
||||
|
||||
if cache_key in self.memory_cache:
|
||||
del self.memory_cache[cache_key]
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cache: {e}")
|
||||
|
||||
async def delete_pattern(self, pattern: str):
|
||||
"""Eliminar por patrón"""
|
||||
if not self.enabled or not self.redis_client:
|
||||
return
|
||||
|
||||
try:
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
self.redis_client.delete(*keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting pattern: {e}")
|
||||
|
||||
async def clear_all(self):
|
||||
"""Limpiar todo el caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.redis_client:
|
||||
self.redis_client.flushdb()
|
||||
self.memory_cache.clear()
|
||||
logger.info("Cache cleared")
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
|
||||
|
||||
# Singleton
|
||||
cache_client = CacheClient()
|
||||
|
||||
|
||||
def cached(prefix: str, ttl: int = 60, key_builder: Optional[Callable] = None):
|
||||
"""
|
||||
Decorador para cachear respuestas de endpoints
|
||||
|
||||
Uso:
|
||||
@cached(prefix="eta", ttl=30)
|
||||
async def get_eta(address_id: int):
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
if not cache_client.enabled:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Construir clave
|
||||
if key_builder:
|
||||
cache_key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Usar nombre de función y argumentos
|
||||
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
||||
|
||||
# Intentar obtener del caché
|
||||
cached_value = await cache_client.get(prefix, cache_key)
|
||||
if cached_value is not None:
|
||||
logger.debug(f"Cache HIT: {prefix}:{cache_key}")
|
||||
return cached_value
|
||||
|
||||
# Ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en caché
|
||||
if result is not None:
|
||||
await cache_client.set(prefix, cache_key, result, ttl)
|
||||
logger.debug(f"Cache MISS: {prefix}:{cache_key} saved")
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
async def invalidate_user_cache(user_id: int):
|
||||
"""Invalidar caché relacionada con un usuario"""
|
||||
await cache_client.delete_pattern(f"addresses:user:{user_id}:*")
|
||||
await cache_client.delete_pattern(f"eta:user:{user_id}:*")
|
||||
logger.info(f"Cache invalidated for user {user_id}")
|
||||
|
||||
|
||||
async def invalidate_route_cache(route_id: str):
|
||||
"""Invalidar caché relacionada con una ruta"""
|
||||
await cache_client.delete_pattern(f"eta:route:{route_id}:*")
|
||||
await cache_client.delete_pattern(f"truck_status:{route_id}")
|
||||
logger.info(f"Cache invalidated for route {route_id}")
|
||||
42
server/app/core/config.py
Normal file
42
server/app/core/config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "BasuraApp API"
|
||||
debug: bool = True
|
||||
secret_key: str = "CAMBIA_ESTO_EN_PRODUCCION_clave_super_secreta"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 horas
|
||||
|
||||
# Database paths
|
||||
database_path: Optional[str] = None
|
||||
database_url: str = "sqlite:///./basura.db"
|
||||
|
||||
# Supabase
|
||||
supabase_url: Optional[str] = None
|
||||
supabase_anon_key: Optional[str] = None
|
||||
supabase_service_key: Optional[str] = None
|
||||
|
||||
# Redis
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_db: int = 0
|
||||
redis_password: Optional[str] = None
|
||||
|
||||
# Cache settings
|
||||
cache_enabled: bool = True
|
||||
cache_ttl_eta: int = 30 # 30 segundos para ETA
|
||||
cache_ttl_addresses: int = 300 # 5 minutos para direcciones
|
||||
cache_ttl_guide: int = 86400 # 24 horas para guía de reciclaje
|
||||
|
||||
# Simulador
|
||||
sim_tick_seconds: int = 10
|
||||
sim_eta_alert_minutes: int = 10
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "allow" # Permitir variables extras del .env
|
||||
|
||||
|
||||
settings = Settings()
|
||||
43
server/app/core/dependencies.py
Normal file
43
server/app/core/dependencies.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Middleware para validar JWT y retornar usuario actual."""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.algorithm]
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
# Obtener usuario de Supabase
|
||||
db = get_db()
|
||||
try:
|
||||
result = db.table("users").select("id, email, phone").eq("id", user_id).execute()
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
user = result.data[0]
|
||||
return {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"phone": user["phone"]
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"DB error: {str(e)}")
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
3
server/app/data/__init__.py
Normal file
3
server/app/data/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
|
||||
__all__ = ["SQLiteRutaRepository"]
|
||||
0
server/app/data/models/__init__.py
Normal file
0
server/app/data/models/__init__.py
Normal file
3
server/app/data/repositories/__init__.py
Normal file
3
server/app/data/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
|
||||
__all__ = ["SQLiteRutaRepository"]
|
||||
198
server/app/data/repositories/ruta_repository.py
Normal file
198
server/app/data/repositories/ruta_repository.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Capa de Datos — Supabase PostgreSQL con caché
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada, EstadoCamion, ETAResult,
|
||||
NotificationPreferences, PuntoRuta, Ruta, TruckStatus,
|
||||
)
|
||||
|
||||
|
||||
class SupabaseRutaRepository:
|
||||
|
||||
def __init__(self):
|
||||
self.db = get_db()
|
||||
|
||||
# ── Rutas y puntos ────────────────────────────────────────────────
|
||||
|
||||
def obtener_ruta(self, route_id: str) -> Optional[Ruta]:
|
||||
try:
|
||||
ruta_data = self.db.table("rutas").select("*").eq("id", route_id).execute()
|
||||
if not ruta_data.data:
|
||||
return None
|
||||
|
||||
ruta_row = ruta_data.data[0]
|
||||
puntos_data = self.db.table("puntos_ruta").select("*").eq("ruta_id", route_id).order("orden").execute()
|
||||
|
||||
puntos = [
|
||||
PuntoRuta(
|
||||
orden=p["orden"],
|
||||
nombre=p["nombre"],
|
||||
coordenada=Coordenada(p["lat"], p["lng"]),
|
||||
tiempo_estimado_min=p["tiempo_estimado_min"],
|
||||
)
|
||||
for p in puntos_data.data
|
||||
]
|
||||
|
||||
return Ruta(
|
||||
id=ruta_row["id"],
|
||||
nombre=ruta_row["nombre"],
|
||||
puntos=puntos,
|
||||
turno=ruta_row["turno"]
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error obtener_ruta: {e}")
|
||||
return None
|
||||
|
||||
def obtener_ruta_por_address(self, address_id: int) -> Optional[Ruta]:
|
||||
try:
|
||||
addr_data = self.db.table("addresses").select("route_id").eq("id", address_id).execute()
|
||||
if not addr_data.data:
|
||||
return None
|
||||
return self.obtener_ruta(addr_data.data[0]["route_id"])
|
||||
except Exception as e:
|
||||
print(f"Error obtener_ruta_por_address: {e}")
|
||||
return None
|
||||
|
||||
# ── truck_status ──────────────────────────────────────────
|
||||
|
||||
def obtener_truck_status(self, route_id: str) -> Optional[TruckStatus]:
|
||||
try:
|
||||
ts_data = self.db.table("truck_status").select("*").eq("route_id", route_id).execute()
|
||||
if not ts_data.data:
|
||||
return None
|
||||
|
||||
row = ts_data.data[0]
|
||||
return TruckStatus(
|
||||
route_id=row["route_id"],
|
||||
current_position_id=row["current_position_id"],
|
||||
last_update=datetime.fromisoformat(row["last_update"].replace("Z", "+00:00")),
|
||||
status=EstadoCamion(row["status"]),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error obtener_truck_status: {e}")
|
||||
return None
|
||||
|
||||
def guardar_truck_status(self, ts: TruckStatus) -> None:
|
||||
try:
|
||||
self.db.table("truck_status").upsert({
|
||||
"route_id": ts.route_id,
|
||||
"current_position_id": ts.current_position_id,
|
||||
"last_update": ts.last_update.isoformat(),
|
||||
"status": ts.status.value,
|
||||
}).execute()
|
||||
|
||||
# Invalidar caché
|
||||
from app.core.cache import invalidate_route_cache
|
||||
import asyncio
|
||||
asyncio.create_task(invalidate_route_cache(ts.route_id))
|
||||
except Exception as e:
|
||||
print(f"Error guardar_truck_status: {e}")
|
||||
|
||||
# ── Preferencias de notificación ─────────────────────────────────
|
||||
|
||||
def obtener_preferencias(self, user_id: str) -> NotificationPreferences:
|
||||
try:
|
||||
prefs_data = self.db.table("notification_preferences").select("*").eq("user_id", user_id).execute()
|
||||
if not prefs_data.data:
|
||||
return NotificationPreferences(user_id=user_id)
|
||||
|
||||
row = prefs_data.data[0]
|
||||
return NotificationPreferences(
|
||||
user_id=user_id,
|
||||
notify_proximity=row["notify_proximity"],
|
||||
notify_breakdown=row["notify_breakdown"],
|
||||
notify_delay=row["notify_delay"],
|
||||
notify_route_start=row["notify_route_start"],
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error obtener_preferencias: {e}")
|
||||
return NotificationPreferences(user_id=user_id)
|
||||
|
||||
def obtener_usuarios_por_ruta(self, route_id: str) -> list[dict]:
|
||||
try:
|
||||
users_data = self.db.table("addresses").select("id, user_id").eq("route_id", route_id).execute()
|
||||
return [{"address_id": u["id"], "user_id": u["user_id"]} for u in users_data.data]
|
||||
except Exception as e:
|
||||
print(f"Error obtener_usuarios_por_ruta: {e}")
|
||||
return []
|
||||
|
||||
# ── Templates de notificación ─────────────────────────────────────
|
||||
|
||||
def obtener_template(self, trigger_event: str) -> Optional[dict]:
|
||||
try:
|
||||
template_data = self.db.table("notification_templates").select("*").eq("trigger_event", trigger_event).execute()
|
||||
if not template_data.data:
|
||||
return None
|
||||
return template_data.data[0]
|
||||
except Exception as e:
|
||||
print(f"Error obtener_template: {e}")
|
||||
return None
|
||||
|
||||
# ── ETA calculado ────────────────────────────────────────────────
|
||||
|
||||
def calcular_eta(self, address_id: int) -> Optional[ETAResult]:
|
||||
ruta = self.obtener_ruta_por_address(address_id)
|
||||
if not ruta:
|
||||
return None
|
||||
|
||||
ts = self.obtener_truck_status(ruta.id)
|
||||
if not ts:
|
||||
return ETAResult(
|
||||
address_id=address_id, route_id=ruta.id,
|
||||
status="SIN_INICIAR", eta_minutos=None,
|
||||
ventana_inicio=None, ventana_fin=None,
|
||||
mensaje="El camión aún no ha iniciado su ruta.",
|
||||
)
|
||||
|
||||
pos = ts.current_position_id
|
||||
puntos = {p.orden: p for p in ruta.puntos}
|
||||
ultimo = ruta.puntos[-1]
|
||||
actual = puntos.get(pos, ruta.puntos[0])
|
||||
|
||||
eta_min = max(0, ultimo.tiempo_estimado_min - actual.tiempo_estimado_min)
|
||||
|
||||
ahora = datetime.now()
|
||||
llegada = ahora + timedelta(minutes=eta_min)
|
||||
v_ini = (llegada - timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
|
||||
v_fin = (llegada + timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
|
||||
|
||||
if ts.status == EstadoCamion.APROXIMANDOSE:
|
||||
msg = f"El camión llegará a tu zona entre las {v_ini} y {v_fin}."
|
||||
elif ts.status in (EstadoCamion.AVERIADA, EstadoCamion.RETRASADA):
|
||||
msg = "El camión reportó una incidencia. Te notificaremos cuando se reanude."
|
||||
v_ini = v_fin = None
|
||||
else:
|
||||
msg = f"El camión está en camino. Llegada estimada: {v_ini} – {v_fin}."
|
||||
|
||||
return ETAResult(
|
||||
address_id=address_id,
|
||||
route_id=ruta.id,
|
||||
status=ts.status.value,
|
||||
eta_minutos=eta_min,
|
||||
ventana_inicio=v_ini,
|
||||
ventana_fin=v_fin,
|
||||
mensaje=msg,
|
||||
)
|
||||
|
||||
def guardar_notificacion(self, tipo: str, route_id: str,
|
||||
address_id: int, mensaje: str,
|
||||
eta_minutos: Optional[int]) -> None:
|
||||
try:
|
||||
self.db.table("notificaciones").insert({
|
||||
"tipo": tipo,
|
||||
"ruta_id": route_id,
|
||||
"address_id": address_id,
|
||||
"mensaje": mensaje,
|
||||
"eta_minutos": eta_minutos,
|
||||
"creada_en": datetime.utcnow().isoformat(),
|
||||
}).execute()
|
||||
except Exception as e:
|
||||
print(f"Error guardar_notificacion: {e}")
|
||||
|
||||
|
||||
# Alias para compatibilidad
|
||||
SQLiteRutaRepository = SupabaseRutaRepository
|
||||
3
server/app/db/__init__.py
Normal file
3
server/app/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.db.database import get_connection, init_db
|
||||
|
||||
__all__ = ["get_connection", "init_db"]
|
||||
124
server/app/db/database.py
Normal file
124
server/app/db/database.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Base de datos Supabase PostgreSQL — conexión y utilidades.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path, override=True)
|
||||
|
||||
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
||||
SUPABASE_KEY = os.getenv("SUPABASE_ANON_KEY")
|
||||
|
||||
if not SUPABASE_URL or not SUPABASE_KEY:
|
||||
raise ValueError("Missing SUPABASE_URL or SUPABASE_ANON_KEY in .env file")
|
||||
|
||||
try:
|
||||
from supabase import create_client, Client
|
||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Supabase sync client failed: {e}. Using mock client for now.")
|
||||
import uuid
|
||||
class MockResponse:
|
||||
def __init__(self, data=None):
|
||||
self.data = data or []
|
||||
class MockTable:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
self.mode = None
|
||||
def select(self, *args):
|
||||
self.mode = "select"
|
||||
return self
|
||||
def insert(self, data):
|
||||
self.data = data
|
||||
self.mode = "insert"
|
||||
return self
|
||||
def eq(self, field, value):
|
||||
self.mode = "eq"
|
||||
self.filter_field = field
|
||||
self.filter_value = value
|
||||
return self
|
||||
def execute(self):
|
||||
if self.mode == "insert":
|
||||
row = {**self.data, "id": str(uuid.uuid4())}
|
||||
return MockResponse([row])
|
||||
return MockResponse([])
|
||||
class MockClient:
|
||||
def table(self, name):
|
||||
return MockTable()
|
||||
supabase = MockClient()
|
||||
|
||||
|
||||
def get_db() -> Client:
|
||||
"""Retorna cliente Supabase."""
|
||||
return supabase
|
||||
|
||||
|
||||
def get_connection() -> Client:
|
||||
"""Alias for get_db for backwards compatibility."""
|
||||
return get_db()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
Inicializa BD. En Supabase, tablas ya existen en el schema_supabase.sql.
|
||||
Esta función valida conexión y seed data si es necesario.
|
||||
"""
|
||||
try:
|
||||
# Valida conexión leyendo rutas
|
||||
result = supabase.table("rutas").select("id").eq("id", "RUTA-01").execute()
|
||||
if not result.data:
|
||||
# Seed data si no existe
|
||||
_seed_datos_demo()
|
||||
print("[OK] BD Supabase inicializada")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error inicializacion BD: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _seed_datos_demo() -> None:
|
||||
"""Inserta datos demo si no existen."""
|
||||
try:
|
||||
# Rutas
|
||||
supabase.table("rutas").insert({
|
||||
"id": "RUTA-01",
|
||||
"nombre": "Ruta 01 — Sector Centro",
|
||||
"turno": "mañana"
|
||||
}).execute()
|
||||
|
||||
# Puntos ruta
|
||||
puntos = [
|
||||
{"ruta_id": "RUTA-01", "orden": 1, "nombre": "Estación Central", "lat": 20.5238, "lng": -100.8143, "tiempo_estimado_min": 0},
|
||||
{"ruta_id": "RUTA-01", "orden": 2, "nombre": "Col. Independencia", "lat": 20.5255, "lng": -100.8090, "tiempo_estimado_min": 8},
|
||||
{"ruta_id": "RUTA-01", "orden": 3, "nombre": "Blvd. A. López Mateos", "lat": 20.5271, "lng": -100.8021, "tiempo_estimado_min": 18},
|
||||
{"ruta_id": "RUTA-01", "orden": 4, "nombre": "Col. Jardines del Bosque", "lat": 20.5290, "lng": -100.7965, "tiempo_estimado_min": 28},
|
||||
{"ruta_id": "RUTA-01", "orden": 5, "nombre": "Mercado Hidalgo", "lat": 20.5310, "lng": -100.7910, "tiempo_estimado_min": 38},
|
||||
]
|
||||
for punto in puntos:
|
||||
supabase.table("puntos_ruta").insert(punto).execute()
|
||||
|
||||
# Truck status
|
||||
supabase.table("truck_status").insert({
|
||||
"route_id": "RUTA-01",
|
||||
"current_position_id": 1,
|
||||
"status": "EN_RUTA"
|
||||
}).execute()
|
||||
|
||||
# Notification templates
|
||||
templates = [
|
||||
{"trigger_event": "ruta_iniciada", "title": "Ruta iniciada", "body": "El camión ha comenzado su ruta. Prepárate."},
|
||||
{"trigger_event": "aproximandose", "title": "¡Camión cerca!", "body": "El camión llega en ~{eta} minutos. Saca tu basura."},
|
||||
{"trigger_event": "falla_mecanica", "title": "Aviso de servicio", "body": "El camión reportó una falla. Te notificaremos cuando se reanude."},
|
||||
{"trigger_event": "ruta_tarde", "title": "Cambio de horario", "body": "El camión de la mañana pasará en el turno de la tarde."},
|
||||
{"trigger_event": "completado", "title": "Ruta completada", "body": "El camión completó su paso por tu zona. ¡Hasta mañana!"},
|
||||
]
|
||||
for template in templates:
|
||||
try:
|
||||
supabase.table("notification_templates").insert(template).execute()
|
||||
except:
|
||||
pass # Ignorar duplicados
|
||||
|
||||
print("[OK] Datos demo insertados")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Error seed data: {e}")
|
||||
21
server/app/domain/__init__.py
Normal file
21
server/app/domain/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada,
|
||||
EstadoCamion,
|
||||
ETAResult,
|
||||
NotificationPreferences,
|
||||
PuntoRuta,
|
||||
Ruta,
|
||||
TruckStatus,
|
||||
TipoNotificacion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Coordenada",
|
||||
"EstadoCamion",
|
||||
"ETAResult",
|
||||
"NotificationPreferences",
|
||||
"PuntoRuta",
|
||||
"Ruta",
|
||||
"TruckStatus",
|
||||
"TipoNotificacion",
|
||||
]
|
||||
21
server/app/domain/entities/__init__.py
Normal file
21
server/app/domain/entities/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada,
|
||||
EstadoCamion,
|
||||
ETAResult,
|
||||
NotificationPreferences,
|
||||
PuntoRuta,
|
||||
Ruta,
|
||||
TruckStatus,
|
||||
TipoNotificacion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Coordenada",
|
||||
"EstadoCamion",
|
||||
"ETAResult",
|
||||
"NotificationPreferences",
|
||||
"PuntoRuta",
|
||||
"Ruta",
|
||||
"TruckStatus",
|
||||
"TipoNotificacion",
|
||||
]
|
||||
77
server/app/domain/entities/ruta.py
Normal file
77
server/app/domain/entities/ruta.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Entidades del dominio — sin dependencias externas.
|
||||
Alineadas con el esquema de Persona A.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EstadoCamion(str, Enum):
|
||||
EN_RUTA = "EN_RUTA"
|
||||
APROXIMANDOSE = "APROXIMANDOSE"
|
||||
COMPLETADO = "COMPLETADO"
|
||||
AVERIADA = "AVERIADA" # truck_status: AVERIADA
|
||||
RETRASADA = "RETRASADA" # truck_status: RETRASADA
|
||||
|
||||
|
||||
class TipoNotificacion(str, Enum):
|
||||
RUTA_INICIADA = "ruta_iniciada"
|
||||
APROXIMANDOSE = "aproximandose"
|
||||
COMPLETADO = "completado"
|
||||
FALLA_MECANICA = "falla_mecanica"
|
||||
RUTA_TARDE = "ruta_tarde"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordenada:
|
||||
lat: float
|
||||
lng: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuntoRuta:
|
||||
orden: int # == current_position_id en truck_status
|
||||
nombre: str
|
||||
coordenada: Coordenada
|
||||
tiempo_estimado_min: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ruta:
|
||||
id: str # ej. "RUTA-01"
|
||||
nombre: str
|
||||
puntos: list[PuntoRuta] = field(default_factory=list)
|
||||
turno: str = "mañana"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TruckStatus:
|
||||
"""Espejo directo de la tabla truck_status de Persona A."""
|
||||
route_id: str
|
||||
current_position_id: int
|
||||
last_update: datetime
|
||||
status: EstadoCamion
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationPreferences:
|
||||
"""Preferencias del usuario — leídas antes de cada notificación."""
|
||||
user_id: int
|
||||
notify_proximity: bool = True
|
||||
notify_breakdown: bool = True
|
||||
notify_delay: bool = True
|
||||
notify_route_start: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ETAResult:
|
||||
"""Lo que ve el ciudadano — sin coordenadas, sin índice de waypoint."""
|
||||
address_id: int
|
||||
route_id: str
|
||||
status: str
|
||||
eta_minutos: Optional[int]
|
||||
ventana_inicio: Optional[str] # ej. "7:20 pm"
|
||||
ventana_fin: Optional[str] # ej. "7:35 pm"
|
||||
mensaje: str
|
||||
3
server/app/domain/interfaces/__init__.py
Normal file
3
server/app/domain/interfaces/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.domain.interfaces.i_ruta_repository import IRutaRepository
|
||||
|
||||
__all__ = ["IRutaRepository"]
|
||||
32
server/app/domain/interfaces/i_ruta_repository.py
Normal file
32
server/app/domain/interfaces/i_ruta_repository.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Interfaces del dominio.
|
||||
El dominio define QUÉ necesita, no CÓMO se implementa.
|
||||
La capa de Datos implementa estas interfaces.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from app.domain.entities.ruta import EstadoRuta, Ruta
|
||||
|
||||
|
||||
class IRutaRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def obtener_ruta(self, ruta_id: str) -> Optional[Ruta]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def obtener_estado(self, ruta_id: str) -> Optional[EstadoRuta]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def guardar_estado(self, estado: EstadoRuta) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def obtener_ruta_por_zona(self, zona_id: str) -> Optional[Ruta]:
|
||||
"""
|
||||
Devuelve la ruta asignada a una zona.
|
||||
Cumple la restricción de 'túnel': el domicilio solo ve su ruta.
|
||||
"""
|
||||
...
|
||||
48
server/app/main.py
Normal file
48
server/app/main.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Punto de entrada de la aplicación.
|
||||
Ejecutar con: uvicorn app.main:app --reload
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes.eta_router import router as eta_router
|
||||
from app.api.routes.auth_router import router as auth_router
|
||||
from app.api.routes.addresses_router import router as addresses_router
|
||||
from app.api.routes.guide_router import router as guide_router # ← IMPORTANTE: agregar esta línea
|
||||
from app.core.config import settings
|
||||
from app.db.database import init_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
description="API de notificación inteligente de recolección de residuos",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db()
|
||||
logging.info("Base de datos inicializada ✓")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
app.include_router(addresses_router, prefix="/addresses", tags=["Addresses"])
|
||||
app.include_router(eta_router, tags=["ETA / Simulador"])
|
||||
app.include_router(guide_router, tags=["Recycling Guide"])
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health():
|
||||
return {"status": "ok", "app": settings.app_name}
|
||||
4
server/app/services/__init__.py
Normal file
4
server/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.simulador import SimuladorRuta, obtener_simulador
|
||||
from app.services.ws_manager import ws_manager
|
||||
|
||||
__all__ = ["SimuladorRuta", "obtener_simulador", "ws_manager"]
|
||||
214
server/app/services/simulador.py
Normal file
214
server/app/services/simulador.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Simulador de Ruta — Módulo B
|
||||
|
||||
Avanza `truck_status.current_position_id` cada tick.
|
||||
Antes de cada push verifica `notification_preferences` del usuario.
|
||||
Nunca envía coordenadas al cliente — solo ETA + mensaje.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.data.repositories.ruta_repository import SupabaseRutaRepository
|
||||
from app.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion
|
||||
from app.services.ws_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimuladorRuta:
|
||||
|
||||
def __init__(self, route_id: str, tick_segundos: int = None):
|
||||
self.route_id = route_id
|
||||
self.tick = tick_segundos or settings.sim_tick_seconds
|
||||
self.repo = SupabaseRutaRepository()
|
||||
self._tarea: Optional[asyncio.Task] = None
|
||||
self._corriendo = False
|
||||
|
||||
# ── Control ────────────────────────────────────────────────────────
|
||||
|
||||
def iniciar(self) -> None:
|
||||
if self._corriendo:
|
||||
return
|
||||
self._corriendo = True
|
||||
self._tarea = asyncio.create_task(self._loop())
|
||||
logger.info(f"[SIM] {self.route_id} iniciada")
|
||||
|
||||
def detener(self) -> None:
|
||||
self._corriendo = False
|
||||
if self._tarea:
|
||||
self._tarea.cancel()
|
||||
|
||||
async def forzar_averia(self, mensaje: str = "Falla mecánica reportada.") -> None:
|
||||
"""Endpoint /alerts/breakdown llama esto."""
|
||||
ts = self.repo.obtener_truck_status(self.route_id)
|
||||
if not ts:
|
||||
return
|
||||
ts.status = EstadoCamion.AVERIADA
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
self.detener()
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.FALLA_MECANICA,
|
||||
eta_minutos=None,
|
||||
mensaje=mensaje,
|
||||
preferencia_key="notify_breakdown",
|
||||
)
|
||||
logger.warning(f"[SIM] Avería registrada en {self.route_id}")
|
||||
|
||||
async def forzar_retraso(self, mensaje: str = "El camión reportó un retraso.") -> None:
|
||||
ts = self.repo.obtener_truck_status(self.route_id)
|
||||
if not ts:
|
||||
return
|
||||
ts.status = EstadoCamion.RETRASADA
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.RUTA_TARDE,
|
||||
eta_minutos=None,
|
||||
mensaje=mensaje,
|
||||
preferencia_key="notify_delay",
|
||||
)
|
||||
|
||||
# ── Loop principal ─────────────────────────────────────────────────
|
||||
|
||||
async def _loop(self) -> None:
|
||||
ruta = self.repo.obtener_ruta(self.route_id)
|
||||
if not ruta or not ruta.puntos:
|
||||
logger.error(f"[SIM] Ruta {self.route_id} sin puntos")
|
||||
return
|
||||
|
||||
# Inicializar truck_status en posición 1
|
||||
ts = TruckStatus(
|
||||
route_id=self.route_id,
|
||||
current_position_id=1,
|
||||
last_update=datetime.utcnow(),
|
||||
status=EstadoCamion.EN_RUTA,
|
||||
)
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.RUTA_INICIADA,
|
||||
eta_minutos=ruta.puntos[-1].tiempo_estimado_min,
|
||||
mensaje="El camión ha iniciado su ruta. Prepárate.",
|
||||
preferencia_key="notify_route_start",
|
||||
)
|
||||
|
||||
umbral = settings.sim_eta_alert_minutes
|
||||
ultimo_punto = ruta.puntos[-1]
|
||||
|
||||
for punto in ruta.puntos[1:]:
|
||||
if not self._corriendo:
|
||||
break
|
||||
|
||||
await asyncio.sleep(self.tick)
|
||||
|
||||
eta = max(0, ultimo_punto.tiempo_estimado_min - punto.tiempo_estimado_min)
|
||||
|
||||
# Detectar umbral de proximidad
|
||||
if eta <= umbral and ts.status == EstadoCamion.EN_RUTA:
|
||||
ts.status = EstadoCamion.APROXIMANDOSE
|
||||
tipo = TipoNotificacion.APROXIMANDOSE
|
||||
pref_key = "notify_proximity"
|
||||
msg = (
|
||||
f"El camión llega en ~{eta} minutos. "
|
||||
"Saca tu basura ahora."
|
||||
)
|
||||
else:
|
||||
tipo = TipoNotificacion.RUTA_INICIADA
|
||||
pref_key = "notify_route_start"
|
||||
msg = f"El camión está en camino. Llegada estimada en ~{eta} min."
|
||||
|
||||
ts.current_position_id = punto.orden
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=tipo,
|
||||
eta_minutos=eta,
|
||||
mensaje=msg,
|
||||
preferencia_key=pref_key,
|
||||
)
|
||||
logger.info(f"[SIM] Pos {punto.orden} | ETA {eta} min | {ts.status}")
|
||||
|
||||
if self._corriendo:
|
||||
ts.status = EstadoCamion.COMPLETADO
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.COMPLETADO,
|
||||
eta_minutos=0,
|
||||
mensaje="El camión completó su paso. ¡Hasta mañana!",
|
||||
preferencia_key=None, # completado siempre se notifica
|
||||
)
|
||||
self._corriendo = False
|
||||
logger.info(f"[SIM] {self.route_id} completada")
|
||||
|
||||
# ── Broadcast respetando preferencias ─────────────────────────────
|
||||
|
||||
async def _broadcast_a_usuarios(
|
||||
self,
|
||||
tipo: TipoNotificacion,
|
||||
eta_minutos: Optional[int],
|
||||
mensaje: str,
|
||||
preferencia_key: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Por cada domicilio en la ruta:
|
||||
1. Consulta las preferencias del usuario.
|
||||
2. Solo envía si la preferencia está activa.
|
||||
3. Persiste la notificación en BD.
|
||||
4. Empuja por WebSocket al address_id correspondiente.
|
||||
"""
|
||||
template = self.repo.obtener_template(tipo.value)
|
||||
if template and "{eta}" in template["body"]:
|
||||
mensaje = template["body"].replace("{eta}", str(eta_minutos or "?"))
|
||||
|
||||
usuarios = self.repo.obtener_usuarios_por_ruta(self.route_id)
|
||||
|
||||
for u in usuarios:
|
||||
user_id = u["user_id"]
|
||||
address_id = u["address_id"]
|
||||
|
||||
# Verificar preferencia
|
||||
if preferencia_key:
|
||||
prefs = self.repo.obtener_preferencias(user_id)
|
||||
if not getattr(prefs, preferencia_key, True):
|
||||
logger.debug(
|
||||
f"[SIM] Usuario {user_id} desactivó {preferencia_key}, skip"
|
||||
)
|
||||
continue
|
||||
|
||||
payload = {
|
||||
"tipo": tipo.value,
|
||||
"address_id": address_id,
|
||||
"eta_minutos": eta_minutos,
|
||||
"mensaje": mensaje,
|
||||
"hora_utc": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Push WebSocket — el cliente escucha en /ws/{address_id}
|
||||
await ws_manager.broadcast_zona(str(address_id), payload)
|
||||
|
||||
# Persistir
|
||||
self.repo.guardar_notificacion(
|
||||
tipo=tipo.value,
|
||||
route_id=self.route_id,
|
||||
address_id=address_id,
|
||||
mensaje=mensaje,
|
||||
eta_minutos=eta_minutos,
|
||||
)
|
||||
|
||||
|
||||
# ── Registro global ────────────────────────────────────────────────────
|
||||
_simuladores: dict[str, SimuladorRuta] = {}
|
||||
|
||||
|
||||
def obtener_simulador(route_id: str) -> SimuladorRuta:
|
||||
if route_id not in _simuladores:
|
||||
_simuladores[route_id] = SimuladorRuta(route_id)
|
||||
return _simuladores[route_id]
|
||||
55
server/app/services/ws_manager.py
Normal file
55
server/app/services/ws_manager.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Gestor de conexiones WebSocket.
|
||||
Mantiene un registro de qué clientes están conectados y a qué zona pertenecen.
|
||||
El simulador llama a broadcast_zona() para empujar eventos sin polling.
|
||||
"""
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
# zona_id -> lista de WebSockets activos
|
||||
self._conexiones: dict[str, list[WebSocket]] = defaultdict(list)
|
||||
|
||||
async def conectar(self, websocket: WebSocket, zona_id: str) -> None:
|
||||
await websocket.accept()
|
||||
self._conexiones[zona_id].append(websocket)
|
||||
|
||||
def desconectar(self, websocket: WebSocket, zona_id: str) -> None:
|
||||
conexiones = self._conexiones.get(zona_id, [])
|
||||
if websocket in conexiones:
|
||||
conexiones.remove(websocket)
|
||||
|
||||
async def broadcast_zona(self, zona_id: str, payload: dict) -> None:
|
||||
"""Envía un mensaje a todos los clientes de una zona."""
|
||||
mensaje = json.dumps(payload, ensure_ascii=False)
|
||||
muertos: list[WebSocket] = []
|
||||
|
||||
for ws in self._conexiones.get(zona_id, []):
|
||||
try:
|
||||
await ws.send_text(mensaje)
|
||||
except Exception:
|
||||
muertos.append(ws)
|
||||
|
||||
for ws in muertos:
|
||||
self.desconectar(ws, zona_id)
|
||||
|
||||
async def broadcast_ruta(self, ruta_id: str, payload: dict) -> None:
|
||||
"""
|
||||
Envía a TODAS las zonas de una ruta.
|
||||
El filtro real de privacidad está en el backend (RBAC del endpoint REST).
|
||||
Aquí simplemente distribuimos por zona registrada.
|
||||
"""
|
||||
for zona_id, conexiones in self._conexiones.items():
|
||||
if conexiones:
|
||||
await self.broadcast_zona(zona_id, payload)
|
||||
|
||||
def zonas_activas(self) -> list[str]:
|
||||
return [z for z, ws in self._conexiones.items() if ws]
|
||||
|
||||
|
||||
# Singleton global compartido por el simulador y el router de WebSocket
|
||||
ws_manager = WebSocketManager()
|
||||
3
server/app/use_cases/__init__.py
Normal file
3
server/app/use_cases/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.use_cases.obtener_eta import ObtenerETAUseCase, ETAResponse
|
||||
|
||||
__all__ = ["ObtenerETAUseCase", "ETAResponse"]
|
||||
46
server/app/use_cases/obtener_eta.py
Normal file
46
server/app/use_cases/obtener_eta.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Caso de Uso: ObtenerETA
|
||||
Orquesta la lógica: valida que la zona pertenece al usuario,
|
||||
busca la ruta asignada y devuelve solo el ETA.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from app.domain.interfaces.i_ruta_repository import IRutaRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class ETAResponse:
|
||||
zona_id: str
|
||||
estado: str
|
||||
eta_minutos: Optional[int]
|
||||
mensaje: str
|
||||
ruta_nombre: str
|
||||
|
||||
|
||||
class ObtenerETAUseCase:
|
||||
def __init__(self, repo: IRutaRepository):
|
||||
self.repo = repo
|
||||
|
||||
def ejecutar(self, zona_id: str) -> Optional[ETAResponse]:
|
||||
ruta = self.repo.obtener_ruta_por_zona(zona_id)
|
||||
if not ruta:
|
||||
return None
|
||||
|
||||
estado = self.repo.obtener_estado(ruta.id)
|
||||
if not estado:
|
||||
return ETAResponse(
|
||||
zona_id=zona_id,
|
||||
estado="sin_iniciar",
|
||||
eta_minutos=None,
|
||||
mensaje="El camión aún no ha iniciado su ruta para hoy.",
|
||||
ruta_nombre=ruta.nombre,
|
||||
)
|
||||
|
||||
return ETAResponse(
|
||||
zona_id=zona_id,
|
||||
estado=estado.estado.value,
|
||||
eta_minutos=estado.eta_minutos,
|
||||
mensaje=estado.mensaje,
|
||||
ruta_nombre=ruta.nombre,
|
||||
)
|
||||
60
server/docker-compose.yml
Normal file
60
server/docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: basura-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- basura-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: basura-backend
|
||||
ports:
|
||||
- "0.0.0.0:8000:8000"
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
- DATABASE_PATH=/data/basura.db
|
||||
- DEBUG=${DEBUG:-true}
|
||||
- SIM_TICK_SECONDS=${SIM_TICK_SECONDS:-10}
|
||||
- SIM_ETA_ALERT_MINUTES=${SIM_ETA_ALERT_MINUTES:-10}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- CACHE_ENABLED=${CACHE_ENABLED:-true}
|
||||
- CACHE_TTL_ETA=${CACHE_TTL_ETA:-30}
|
||||
- CACHE_TTL_ADDRESSES=${CACHE_TTL_ADDRESSES:-300}
|
||||
- CACHE_TTL_GUIDE=${CACHE_TTL_GUIDE:-86400}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- basura-network
|
||||
|
||||
networks:
|
||||
basura-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
27
server/dockerfile
Normal file
27
server/dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV DATABASE_PATH=/data/basura.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
17
server/requirements.txt
Normal file
17
server/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
websockets==12.0
|
||||
apscheduler==3.10.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.25.0
|
||||
pyjwt==2.8.0
|
||||
bcrypt==4.1.2
|
||||
email-validator==2.1.0
|
||||
redis==5.0.1
|
||||
hiredis==2.3.2
|
||||
supabase==2.3.4
|
||||
psycopg2-binary==2.9.9
|
||||
127
server/schema_supabase.sql
Normal file
127
server/schema_supabase.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- ── Esquema Supabase PostgreSQL (migrado de SQLite) ──────────────
|
||||
|
||||
-- Tabla users (Persona A) — ID como UUID para Supabase Auth
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
fcm_token TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabla addresses (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
alias TEXT,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabla notification_preferences (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_proximity BOOLEAN DEFAULT TRUE,
|
||||
notify_breakdown BOOLEAN DEFAULT TRUE,
|
||||
notify_delay BOOLEAN DEFAULT TRUE,
|
||||
notify_route_start BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Tabla notification_templates (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS notification_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
trigger_event TEXT UNIQUE,
|
||||
title TEXT,
|
||||
body TEXT
|
||||
);
|
||||
|
||||
-- ── Tablas del módulo B ───────────────────────────────────────
|
||||
|
||||
-- Tabla truck_status
|
||||
CREATE TABLE IF NOT EXISTS truck_status (
|
||||
route_id TEXT PRIMARY KEY,
|
||||
current_position_id INTEGER DEFAULT 1,
|
||||
last_update TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'EN_RUTA'
|
||||
);
|
||||
|
||||
-- Tabla rutas
|
||||
CREATE TABLE IF NOT EXISTS rutas (
|
||||
id TEXT PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
turno TEXT NOT NULL DEFAULT 'mañana'
|
||||
);
|
||||
|
||||
-- Tabla puntos_ruta
|
||||
CREATE TABLE IF NOT EXISTS puntos_ruta (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ruta_id TEXT NOT NULL REFERENCES rutas(id),
|
||||
orden INTEGER NOT NULL,
|
||||
nombre TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
tiempo_estimado_min INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Tabla notificaciones
|
||||
CREATE TABLE IF NOT EXISTS notificaciones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo TEXT NOT NULL,
|
||||
ruta_id TEXT NOT NULL,
|
||||
address_id BIGINT,
|
||||
mensaje TEXT NOT NULL,
|
||||
eta_minutos INTEGER,
|
||||
creada_en TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ── Índices para performance ──────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_addresses_user_id ON addresses(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_addresses_route_id ON addresses(route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_puntos_ruta_ruta_id ON puntos_ruta(ruta_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notificaciones_ruta_id ON notificaciones(ruta_id);
|
||||
|
||||
-- ── RLS (Row Level Security) ──────────────────────────────────
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE addresses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notificaciones ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: users solo ven su propio perfil
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid() = id);
|
||||
|
||||
-- Policy: addresses solo de usuario autenticado
|
||||
CREATE POLICY "Users can view own addresses" ON addresses
|
||||
FOR SELECT USING (user_id = auth.uid());
|
||||
|
||||
-- Policy: notification_preferences solo de usuario autenticado
|
||||
CREATE POLICY "Users can view own preferences" ON notification_preferences
|
||||
FOR SELECT USING (user_id = auth.uid());
|
||||
|
||||
-- ── Seed data ──────────────────────────────────────────────────
|
||||
INSERT INTO rutas (id, nombre, turno) VALUES
|
||||
('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO puntos_ruta (ruta_id, orden, nombre, lat, lng, tiempo_estimado_min) VALUES
|
||||
('RUTA-01', 1, 'Estación Central', 20.5238, -100.8143, 0),
|
||||
('RUTA-01', 2, 'Col. Independencia', 20.5255, -100.8090, 8),
|
||||
('RUTA-01', 3, 'Blvd. A. López Mateos', 20.5271, -100.8021, 18),
|
||||
('RUTA-01', 4, 'Col. Jardines del Bosque', 20.5290, -100.7965, 28),
|
||||
('RUTA-01', 5, 'Mercado Hidalgo', 20.5310, -100.7910, 38)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO truck_status (route_id, current_position_id, status) VALUES
|
||||
('RUTA-01', 1, 'EN_RUTA')
|
||||
ON CONFLICT (route_id) DO NOTHING;
|
||||
|
||||
INSERT INTO notification_templates (trigger_event, title, body) VALUES
|
||||
('ruta_iniciada', 'Ruta iniciada', 'El camión ha comenzado su ruta. Prepárate.'),
|
||||
('aproximandose', '¡Camión cerca!', 'El camión llega en ~{eta} minutos. Saca tu basura.'),
|
||||
('falla_mecanica', 'Aviso de servicio', 'El camión reportó una falla. Te notificaremos cuando se reanude.'),
|
||||
('ruta_tarde', 'Cambio de horario', 'El camión de la mañana pasará en el turno de la tarde.'),
|
||||
('completado', 'Ruta completada', 'El camión completó su paso por tu zona. ¡Hasta mañana!')
|
||||
ON CONFLICT (trigger_event) DO NOTHING;
|
||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
222
server/venv/Lib/site-packages/_distutils_hack/__init__.py
Normal file
222
server/venv/Lib/site-packages/_distutils_hack/__init__.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# don't import any costly modules
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
is_pypy = '__pypy__' in sys.builtin_module_names
|
||||
|
||||
|
||||
def warn_distutils_present():
|
||||
if 'distutils' not in sys.modules:
|
||||
return
|
||||
if is_pypy and sys.version_info < (3, 7):
|
||||
# PyPy for 3.6 unconditionally imports distutils, so bypass the warning
|
||||
# https://foss.heptapod.net/pypy/pypy/-/blob/be829135bc0d758997b3566062999ee8b23872b4/lib-python/3/site.py#L250
|
||||
return
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Distutils was imported before Setuptools, but importing Setuptools "
|
||||
"also replaces the `distutils` module in `sys.modules`. This may lead "
|
||||
"to undesirable behaviors or errors. To avoid these issues, avoid "
|
||||
"using distutils directly, ensure that setuptools is installed in the "
|
||||
"traditional way (e.g. not an editable install), and/or make sure "
|
||||
"that setuptools is always imported before distutils."
|
||||
)
|
||||
|
||||
|
||||
def clear_distutils():
|
||||
if 'distutils' not in sys.modules:
|
||||
return
|
||||
import warnings
|
||||
|
||||
warnings.warn("Setuptools is replacing distutils.")
|
||||
mods = [
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == "distutils" or name.startswith("distutils.")
|
||||
]
|
||||
for name in mods:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def enabled():
|
||||
"""
|
||||
Allow selection of distutils by environment variable.
|
||||
"""
|
||||
which = os.environ.get('SETUPTOOLS_USE_DISTUTILS', 'local')
|
||||
return which == 'local'
|
||||
|
||||
|
||||
def ensure_local_distutils():
|
||||
import importlib
|
||||
|
||||
clear_distutils()
|
||||
|
||||
# With the DistutilsMetaFinder in place,
|
||||
# perform an import to cause distutils to be
|
||||
# loaded from setuptools._distutils. Ref #2906.
|
||||
with shim():
|
||||
importlib.import_module('distutils')
|
||||
|
||||
# check that submodules load as expected
|
||||
core = importlib.import_module('distutils.core')
|
||||
assert '_distutils' in core.__file__, core.__file__
|
||||
assert 'setuptools._distutils.log' not in sys.modules
|
||||
|
||||
|
||||
def do_override():
|
||||
"""
|
||||
Ensure that the local copy of distutils is preferred over stdlib.
|
||||
|
||||
See https://github.com/pypa/setuptools/issues/417#issuecomment-392298401
|
||||
for more motivation.
|
||||
"""
|
||||
if enabled():
|
||||
warn_distutils_present()
|
||||
ensure_local_distutils()
|
||||
|
||||
|
||||
class _TrivialRe:
|
||||
def __init__(self, *patterns):
|
||||
self._patterns = patterns
|
||||
|
||||
def match(self, string):
|
||||
return all(pat in string for pat in self._patterns)
|
||||
|
||||
|
||||
class DistutilsMetaFinder:
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
# optimization: only consider top level modules and those
|
||||
# found in the CPython test suite.
|
||||
if path is not None and not fullname.startswith('test.'):
|
||||
return
|
||||
|
||||
method_name = 'spec_for_{fullname}'.format(**locals())
|
||||
method = getattr(self, method_name, lambda: None)
|
||||
return method()
|
||||
|
||||
def spec_for_distutils(self):
|
||||
if self.is_cpython():
|
||||
return
|
||||
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
mod = importlib.import_module('setuptools._distutils')
|
||||
except Exception:
|
||||
# There are a couple of cases where setuptools._distutils
|
||||
# may not be present:
|
||||
# - An older Setuptools without a local distutils is
|
||||
# taking precedence. Ref #2957.
|
||||
# - Path manipulation during sitecustomize removes
|
||||
# setuptools from the path but only after the hook
|
||||
# has been loaded. Ref #2980.
|
||||
# In either case, fall back to stdlib behavior.
|
||||
return
|
||||
|
||||
class DistutilsLoader(importlib.abc.Loader):
|
||||
def create_module(self, spec):
|
||||
mod.__name__ = 'distutils'
|
||||
return mod
|
||||
|
||||
def exec_module(self, module):
|
||||
pass
|
||||
|
||||
return importlib.util.spec_from_loader(
|
||||
'distutils', DistutilsLoader(), origin=mod.__file__
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_cpython():
|
||||
"""
|
||||
Suppress supplying distutils for CPython (build and tests).
|
||||
Ref #2965 and #3007.
|
||||
"""
|
||||
return os.path.isfile('pybuilddir.txt')
|
||||
|
||||
def spec_for_pip(self):
|
||||
"""
|
||||
Ensure stdlib distutils when running under pip.
|
||||
See pypa/pip#8761 for rationale.
|
||||
"""
|
||||
if self.pip_imported_during_build():
|
||||
return
|
||||
clear_distutils()
|
||||
self.spec_for_distutils = lambda: None
|
||||
|
||||
@classmethod
|
||||
def pip_imported_during_build(cls):
|
||||
"""
|
||||
Detect if pip is being imported in a build script. Ref #2355.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
return any(
|
||||
cls.frame_file_is_setup(frame) for frame, line in traceback.walk_stack(None)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def frame_file_is_setup(frame):
|
||||
"""
|
||||
Return True if the indicated frame suggests a setup.py file.
|
||||
"""
|
||||
# some frames may not have __file__ (#2940)
|
||||
return frame.f_globals.get('__file__', '').endswith('setup.py')
|
||||
|
||||
def spec_for_sensitive_tests(self):
|
||||
"""
|
||||
Ensure stdlib distutils when running select tests under CPython.
|
||||
|
||||
python/cpython#91169
|
||||
"""
|
||||
clear_distutils()
|
||||
self.spec_for_distutils = lambda: None
|
||||
|
||||
sensitive_tests = (
|
||||
[
|
||||
'test.test_distutils',
|
||||
'test.test_peg_generator',
|
||||
'test.test_importlib',
|
||||
]
|
||||
if sys.version_info < (3, 10)
|
||||
else [
|
||||
'test.test_distutils',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
for name in DistutilsMetaFinder.sensitive_tests:
|
||||
setattr(
|
||||
DistutilsMetaFinder,
|
||||
f'spec_for_{name}',
|
||||
DistutilsMetaFinder.spec_for_sensitive_tests,
|
||||
)
|
||||
|
||||
|
||||
DISTUTILS_FINDER = DistutilsMetaFinder()
|
||||
|
||||
|
||||
def add_shim():
|
||||
DISTUTILS_FINDER in sys.meta_path or insert_shim()
|
||||
|
||||
|
||||
class shim:
|
||||
def __enter__(self):
|
||||
insert_shim()
|
||||
|
||||
def __exit__(self, exc, value, tb):
|
||||
remove_shim()
|
||||
|
||||
|
||||
def insert_shim():
|
||||
sys.meta_path.insert(0, DISTUTILS_FINDER)
|
||||
|
||||
|
||||
def remove_shim():
|
||||
try:
|
||||
sys.meta_path.remove(DISTUTILS_FINDER)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
__import__('_distutils_hack').do_override()
|
||||
1
server/venv/Lib/site-packages/distutils-precedence.pth
Normal file
1
server/venv/Lib/site-packages/distutils-precedence.pth
Normal file
@@ -0,0 +1 @@
|
||||
import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enabled and __import__('_distutils_hack').add_shim();
|
||||
760
server/venv/Lib/site-packages/pip-24.0.dist-info/AUTHORS.txt
Normal file
760
server/venv/Lib/site-packages/pip-24.0.dist-info/AUTHORS.txt
Normal file
@@ -0,0 +1,760 @@
|
||||
@Switch01
|
||||
A_Rog
|
||||
Aakanksha Agrawal
|
||||
Abhinav Sagar
|
||||
ABHYUDAY PRATAP SINGH
|
||||
abs51295
|
||||
AceGentile
|
||||
Adam Chainz
|
||||
Adam Tse
|
||||
Adam Wentz
|
||||
admin
|
||||
Adrien Morison
|
||||
ahayrapetyan
|
||||
Ahilya
|
||||
AinsworthK
|
||||
Akash Srivastava
|
||||
Alan Yee
|
||||
Albert Tugushev
|
||||
Albert-Guan
|
||||
albertg
|
||||
Alberto Sottile
|
||||
Aleks Bunin
|
||||
Ales Erjavec
|
||||
Alethea Flowers
|
||||
Alex Gaynor
|
||||
Alex Grönholm
|
||||
Alex Hedges
|
||||
Alex Loosley
|
||||
Alex Morega
|
||||
Alex Stachowiak
|
||||
Alexander Shtyrov
|
||||
Alexandre Conrad
|
||||
Alexey Popravka
|
||||
Aleš Erjavec
|
||||
Alli
|
||||
Ami Fischman
|
||||
Ananya Maiti
|
||||
Anatoly Techtonik
|
||||
Anders Kaseorg
|
||||
Andre Aguiar
|
||||
Andreas Lutro
|
||||
Andrei Geacar
|
||||
Andrew Gaul
|
||||
Andrew Shymanel
|
||||
Andrey Bienkowski
|
||||
Andrey Bulgakov
|
||||
Andrés Delfino
|
||||
Andy Freeland
|
||||
Andy Kluger
|
||||
Ani Hayrapetyan
|
||||
Aniruddha Basak
|
||||
Anish Tambe
|
||||
Anrs Hu
|
||||
Anthony Sottile
|
||||
Antoine Musso
|
||||
Anton Ovchinnikov
|
||||
Anton Patrushev
|
||||
Antonio Alvarado Hernandez
|
||||
Antony Lee
|
||||
Antti Kaihola
|
||||
Anubhav Patel
|
||||
Anudit Nagar
|
||||
Anuj Godase
|
||||
AQNOUCH Mohammed
|
||||
AraHaan
|
||||
Arindam Choudhury
|
||||
Armin Ronacher
|
||||
Artem
|
||||
Arun Babu Neelicattu
|
||||
Ashley Manton
|
||||
Ashwin Ramaswami
|
||||
atse
|
||||
Atsushi Odagiri
|
||||
Avinash Karhana
|
||||
Avner Cohen
|
||||
Awit (Ah-Wit) Ghirmai
|
||||
Baptiste Mispelon
|
||||
Barney Gale
|
||||
barneygale
|
||||
Bartek Ogryczak
|
||||
Bastian Venthur
|
||||
Ben Bodenmiller
|
||||
Ben Darnell
|
||||
Ben Hoyt
|
||||
Ben Mares
|
||||
Ben Rosser
|
||||
Bence Nagy
|
||||
Benjamin Peterson
|
||||
Benjamin VanEvery
|
||||
Benoit Pierre
|
||||
Berker Peksag
|
||||
Bernard
|
||||
Bernard Tyers
|
||||
Bernardo B. Marques
|
||||
Bernhard M. Wiedemann
|
||||
Bertil Hatt
|
||||
Bhavam Vidyarthi
|
||||
Blazej Michalik
|
||||
Bogdan Opanchuk
|
||||
BorisZZZ
|
||||
Brad Erickson
|
||||
Bradley Ayers
|
||||
Brandon L. Reiss
|
||||
Brandt Bucher
|
||||
Brett Randall
|
||||
Brett Rosen
|
||||
Brian Cristante
|
||||
Brian Rosner
|
||||
briantracy
|
||||
BrownTruck
|
||||
Bruno Oliveira
|
||||
Bruno Renié
|
||||
Bruno S
|
||||
Bstrdsmkr
|
||||
Buck Golemon
|
||||
burrows
|
||||
Bussonnier Matthias
|
||||
bwoodsend
|
||||
c22
|
||||
Caleb Martinez
|
||||
Calvin Smith
|
||||
Carl Meyer
|
||||
Carlos Liam
|
||||
Carol Willing
|
||||
Carter Thayer
|
||||
Cass
|
||||
Chandrasekhar Atina
|
||||
Chih-Hsuan Yen
|
||||
Chris Brinker
|
||||
Chris Hunt
|
||||
Chris Jerdonek
|
||||
Chris Kuehl
|
||||
Chris McDonough
|
||||
Chris Pawley
|
||||
Chris Pryer
|
||||
Chris Wolfe
|
||||
Christian Clauss
|
||||
Christian Heimes
|
||||
Christian Oudard
|
||||
Christoph Reiter
|
||||
Christopher Hunt
|
||||
Christopher Snyder
|
||||
cjc7373
|
||||
Clark Boylan
|
||||
Claudio Jolowicz
|
||||
Clay McClure
|
||||
Cody
|
||||
Cody Soyland
|
||||
Colin Watson
|
||||
Collin Anderson
|
||||
Connor Osborn
|
||||
Cooper Lees
|
||||
Cooper Ry Lees
|
||||
Cory Benfield
|
||||
Cory Wright
|
||||
Craig Kerstiens
|
||||
Cristian Sorinel
|
||||
Cristina
|
||||
Cristina Muñoz
|
||||
Curtis Doty
|
||||
cytolentino
|
||||
Daan De Meyer
|
||||
Dale
|
||||
Damian
|
||||
Damian Quiroga
|
||||
Damian Shaw
|
||||
Dan Black
|
||||
Dan Savilonis
|
||||
Dan Sully
|
||||
Dane Hillard
|
||||
daniel
|
||||
Daniel Collins
|
||||
Daniel Hahler
|
||||
Daniel Holth
|
||||
Daniel Jost
|
||||
Daniel Katz
|
||||
Daniel Shaulov
|
||||
Daniele Esposti
|
||||
Daniele Nicolodi
|
||||
Daniele Procida
|
||||
Daniil Konovalenko
|
||||
Danny Hermes
|
||||
Danny McClanahan
|
||||
Darren Kavanagh
|
||||
Dav Clark
|
||||
Dave Abrahams
|
||||
Dave Jones
|
||||
David Aguilar
|
||||
David Black
|
||||
David Bordeynik
|
||||
David Caro
|
||||
David D Lowe
|
||||
David Evans
|
||||
David Hewitt
|
||||
David Linke
|
||||
David Poggi
|
||||
David Pursehouse
|
||||
David Runge
|
||||
David Tucker
|
||||
David Wales
|
||||
Davidovich
|
||||
ddelange
|
||||
Deepak Sharma
|
||||
Deepyaman Datta
|
||||
Denise Yu
|
||||
dependabot[bot]
|
||||
derwolfe
|
||||
Desetude
|
||||
Devesh Kumar Singh
|
||||
Diego Caraballo
|
||||
Diego Ramirez
|
||||
DiegoCaraballo
|
||||
Dimitri Merejkowsky
|
||||
Dimitri Papadopoulos
|
||||
Dirk Stolle
|
||||
Dmitry Gladkov
|
||||
Dmitry Volodin
|
||||
Domen Kožar
|
||||
Dominic Davis-Foster
|
||||
Donald Stufft
|
||||
Dongweiming
|
||||
doron zarhi
|
||||
Dos Moonen
|
||||
Douglas Thor
|
||||
DrFeathers
|
||||
Dustin Ingram
|
||||
Dwayne Bailey
|
||||
Ed Morley
|
||||
Edgar Ramírez
|
||||
Edgar Ramírez Mondragón
|
||||
Ee Durbin
|
||||
Efflam Lemaillet
|
||||
efflamlemaillet
|
||||
Eitan Adler
|
||||
ekristina
|
||||
elainechan
|
||||
Eli Schwartz
|
||||
Elisha Hollander
|
||||
Ellen Marie Dash
|
||||
Emil Burzo
|
||||
Emil Styrke
|
||||
Emmanuel Arias
|
||||
Endoh Takanao
|
||||
enoch
|
||||
Erdinc Mutlu
|
||||
Eric Cousineau
|
||||
Eric Gillingham
|
||||
Eric Hanchrow
|
||||
Eric Hopper
|
||||
Erik M. Bray
|
||||
Erik Rose
|
||||
Erwin Janssen
|
||||
Eugene Vereshchagin
|
||||
everdimension
|
||||
Federico
|
||||
Felipe Peter
|
||||
Felix Yan
|
||||
fiber-space
|
||||
Filip Kokosiński
|
||||
Filipe Laíns
|
||||
Finn Womack
|
||||
finnagin
|
||||
Flavio Amurrio
|
||||
Florian Briand
|
||||
Florian Rathgeber
|
||||
Francesco
|
||||
Francesco Montesano
|
||||
Frost Ming
|
||||
Gabriel Curio
|
||||
Gabriel de Perthuis
|
||||
Garry Polley
|
||||
gavin
|
||||
gdanielson
|
||||
Geoffrey Sneddon
|
||||
George Song
|
||||
Georgi Valkov
|
||||
Georgy Pchelkin
|
||||
ghost
|
||||
Giftlin Rajaiah
|
||||
gizmoguy1
|
||||
gkdoc
|
||||
Godefroid Chapelle
|
||||
Gopinath M
|
||||
GOTO Hayato
|
||||
gousaiyang
|
||||
gpiks
|
||||
Greg Roodt
|
||||
Greg Ward
|
||||
Guilherme Espada
|
||||
Guillaume Seguin
|
||||
gutsytechster
|
||||
Guy Rozendorn
|
||||
Guy Tuval
|
||||
gzpan123
|
||||
Hanjun Kim
|
||||
Hari Charan
|
||||
Harsh Vardhan
|
||||
harupy
|
||||
Harutaka Kawamura
|
||||
hauntsaninja
|
||||
Henrich Hartzer
|
||||
Henry Schreiner
|
||||
Herbert Pfennig
|
||||
Holly Stotelmyer
|
||||
Honnix
|
||||
Hsiaoming Yang
|
||||
Hugo Lopes Tavares
|
||||
Hugo van Kemenade
|
||||
Hugues Bruant
|
||||
Hynek Schlawack
|
||||
Ian Bicking
|
||||
Ian Cordasco
|
||||
Ian Lee
|
||||
Ian Stapleton Cordasco
|
||||
Ian Wienand
|
||||
Igor Kuzmitshov
|
||||
Igor Sobreira
|
||||
Ilan Schnell
|
||||
Illia Volochii
|
||||
Ilya Baryshev
|
||||
Inada Naoki
|
||||
Ionel Cristian Mărieș
|
||||
Ionel Maries Cristian
|
||||
Itamar Turner-Trauring
|
||||
Ivan Pozdeev
|
||||
J. Nick Koston
|
||||
Jacob Kim
|
||||
Jacob Walls
|
||||
Jaime Sanz
|
||||
jakirkham
|
||||
Jakub Kuczys
|
||||
Jakub Stasiak
|
||||
Jakub Vysoky
|
||||
Jakub Wilk
|
||||
James Cleveland
|
||||
James Curtin
|
||||
James Firth
|
||||
James Gerity
|
||||
James Polley
|
||||
Jan Pokorný
|
||||
Jannis Leidel
|
||||
Jarek Potiuk
|
||||
jarondl
|
||||
Jason Curtis
|
||||
Jason R. Coombs
|
||||
JasonMo
|
||||
JasonMo1
|
||||
Jay Graves
|
||||
Jean Abou Samra
|
||||
Jean-Christophe Fillion-Robin
|
||||
Jeff Barber
|
||||
Jeff Dairiki
|
||||
Jeff Widman
|
||||
Jelmer Vernooij
|
||||
jenix21
|
||||
Jeremy Stanley
|
||||
Jeremy Zafran
|
||||
Jesse Rittner
|
||||
Jiashuo Li
|
||||
Jim Fisher
|
||||
Jim Garrison
|
||||
Jiun Bae
|
||||
Jivan Amara
|
||||
Joe Bylund
|
||||
Joe Michelini
|
||||
John Paton
|
||||
John T. Wodder II
|
||||
John-Scott Atlakson
|
||||
johnthagen
|
||||
Jon Banafato
|
||||
Jon Dufresne
|
||||
Jon Parise
|
||||
Jonas Nockert
|
||||
Jonathan Herbert
|
||||
Joonatan Partanen
|
||||
Joost Molenaar
|
||||
Jorge Niedbalski
|
||||
Joseph Bylund
|
||||
Joseph Long
|
||||
Josh Bronson
|
||||
Josh Hansen
|
||||
Josh Schneier
|
||||
Joshua
|
||||
Juan Luis Cano Rodríguez
|
||||
Juanjo Bazán
|
||||
Judah Rand
|
||||
Julian Berman
|
||||
Julian Gethmann
|
||||
Julien Demoor
|
||||
Jussi Kukkonen
|
||||
jwg4
|
||||
Jyrki Pulliainen
|
||||
Kai Chen
|
||||
Kai Mueller
|
||||
Kamal Bin Mustafa
|
||||
kasium
|
||||
kaustav haldar
|
||||
keanemind
|
||||
Keith Maxwell
|
||||
Kelsey Hightower
|
||||
Kenneth Belitzky
|
||||
Kenneth Reitz
|
||||
Kevin Burke
|
||||
Kevin Carter
|
||||
Kevin Frommelt
|
||||
Kevin R Patterson
|
||||
Kexuan Sun
|
||||
Kit Randel
|
||||
Klaas van Schelven
|
||||
KOLANICH
|
||||
kpinc
|
||||
Krishna Oza
|
||||
Kumar McMillan
|
||||
Kurt McKee
|
||||
Kyle Persohn
|
||||
lakshmanaram
|
||||
Laszlo Kiss-Kollar
|
||||
Laurent Bristiel
|
||||
Laurent LAPORTE
|
||||
Laurie O
|
||||
Laurie Opperman
|
||||
layday
|
||||
Leon Sasson
|
||||
Lev Givon
|
||||
Lincoln de Sousa
|
||||
Lipis
|
||||
lorddavidiii
|
||||
Loren Carvalho
|
||||
Lucas Cimon
|
||||
Ludovic Gasc
|
||||
Lukas Geiger
|
||||
Lukas Juhrich
|
||||
Luke Macken
|
||||
Luo Jiebin
|
||||
luojiebin
|
||||
luz.paz
|
||||
László Kiss Kollár
|
||||
M00nL1ght
|
||||
Marc Abramowitz
|
||||
Marc Tamlyn
|
||||
Marcus Smith
|
||||
Mariatta
|
||||
Mark Kohler
|
||||
Mark Williams
|
||||
Markus Hametner
|
||||
Martey Dodoo
|
||||
Martin Fischer
|
||||
Martin Häcker
|
||||
Martin Pavlasek
|
||||
Masaki
|
||||
Masklinn
|
||||
Matej Stuchlik
|
||||
Mathew Jennings
|
||||
Mathieu Bridon
|
||||
Mathieu Kniewallner
|
||||
Matt Bacchi
|
||||
Matt Good
|
||||
Matt Maker
|
||||
Matt Robenolt
|
||||
matthew
|
||||
Matthew Einhorn
|
||||
Matthew Feickert
|
||||
Matthew Gilliard
|
||||
Matthew Iversen
|
||||
Matthew Treinish
|
||||
Matthew Trumbell
|
||||
Matthew Willson
|
||||
Matthias Bussonnier
|
||||
mattip
|
||||
Maurits van Rees
|
||||
Max W Chase
|
||||
Maxim Kurnikov
|
||||
Maxime Rouyrre
|
||||
mayeut
|
||||
mbaluna
|
||||
mdebi
|
||||
memoselyk
|
||||
meowmeowcat
|
||||
Michael
|
||||
Michael Aquilina
|
||||
Michael E. Karpeles
|
||||
Michael Klich
|
||||
Michael Mintz
|
||||
Michael Williamson
|
||||
michaelpacer
|
||||
Michał Górny
|
||||
Mickaël Schoentgen
|
||||
Miguel Araujo Perez
|
||||
Mihir Singh
|
||||
Mike
|
||||
Mike Hendricks
|
||||
Min RK
|
||||
MinRK
|
||||
Miro Hrončok
|
||||
Monica Baluna
|
||||
montefra
|
||||
Monty Taylor
|
||||
Muha Ajjan
|
||||
Nadav Wexler
|
||||
Nahuel Ambrosini
|
||||
Nate Coraor
|
||||
Nate Prewitt
|
||||
Nathan Houghton
|
||||
Nathaniel J. Smith
|
||||
Nehal J Wani
|
||||
Neil Botelho
|
||||
Nguyễn Gia Phong
|
||||
Nicholas Serra
|
||||
Nick Coghlan
|
||||
Nick Stenning
|
||||
Nick Timkovich
|
||||
Nicolas Bock
|
||||
Nicole Harris
|
||||
Nikhil Benesch
|
||||
Nikhil Ladha
|
||||
Nikita Chepanov
|
||||
Nikolay Korolev
|
||||
Nipunn Koorapati
|
||||
Nitesh Sharma
|
||||
Niyas Sait
|
||||
Noah
|
||||
Noah Gorny
|
||||
Nowell Strite
|
||||
NtaleGrey
|
||||
nvdv
|
||||
OBITORASU
|
||||
Ofek Lev
|
||||
ofrinevo
|
||||
Oliver Freund
|
||||
Oliver Jeeves
|
||||
Oliver Mannion
|
||||
Oliver Tonnhofer
|
||||
Olivier Girardot
|
||||
Olivier Grisel
|
||||
Ollie Rutherfurd
|
||||
OMOTO Kenji
|
||||
Omry Yadan
|
||||
onlinejudge95
|
||||
Oren Held
|
||||
Oscar Benjamin
|
||||
Oz N Tiram
|
||||
Pachwenko
|
||||
Patrick Dubroy
|
||||
Patrick Jenkins
|
||||
Patrick Lawson
|
||||
patricktokeeffe
|
||||
Patrik Kopkan
|
||||
Paul Ganssle
|
||||
Paul Kehrer
|
||||
Paul Moore
|
||||
Paul Nasrat
|
||||
Paul Oswald
|
||||
Paul van der Linden
|
||||
Paulus Schoutsen
|
||||
Pavel Safronov
|
||||
Pavithra Eswaramoorthy
|
||||
Pawel Jasinski
|
||||
Paweł Szramowski
|
||||
Pekka Klärck
|
||||
Peter Gessler
|
||||
Peter Lisák
|
||||
Peter Waller
|
||||
petr-tik
|
||||
Phaneendra Chiruvella
|
||||
Phil Elson
|
||||
Phil Freo
|
||||
Phil Pennock
|
||||
Phil Whelan
|
||||
Philip Jägenstedt
|
||||
Philip Molloy
|
||||
Philippe Ombredanne
|
||||
Pi Delport
|
||||
Pierre-Yves Rofes
|
||||
Pieter Degroote
|
||||
pip
|
||||
Prabakaran Kumaresshan
|
||||
Prabhjyotsing Surjit Singh Sodhi
|
||||
Prabhu Marappan
|
||||
Pradyun Gedam
|
||||
Prashant Sharma
|
||||
Pratik Mallya
|
||||
pre-commit-ci[bot]
|
||||
Preet Thakkar
|
||||
Preston Holmes
|
||||
Przemek Wrzos
|
||||
Pulkit Goyal
|
||||
q0w
|
||||
Qiangning Hong
|
||||
Qiming Xu
|
||||
Quentin Lee
|
||||
Quentin Pradet
|
||||
R. David Murray
|
||||
Rafael Caricio
|
||||
Ralf Schmitt
|
||||
Razzi Abuissa
|
||||
rdb
|
||||
Reece Dunham
|
||||
Remi Rampin
|
||||
Rene Dudfield
|
||||
Riccardo Magliocchetti
|
||||
Riccardo Schirone
|
||||
Richard Jones
|
||||
Richard Si
|
||||
Ricky Ng-Adam
|
||||
Rishi
|
||||
RobberPhex
|
||||
Robert Collins
|
||||
Robert McGibbon
|
||||
Robert Pollak
|
||||
Robert T. McGibbon
|
||||
robin elisha robinson
|
||||
Roey Berman
|
||||
Rohan Jain
|
||||
Roman Bogorodskiy
|
||||
Roman Donchenko
|
||||
Romuald Brunet
|
||||
ronaudinho
|
||||
Ronny Pfannschmidt
|
||||
Rory McCann
|
||||
Ross Brattain
|
||||
Roy Wellington Ⅳ
|
||||
Ruairidh MacLeod
|
||||
Russell Keith-Magee
|
||||
Ryan Shepherd
|
||||
Ryan Wooden
|
||||
ryneeverett
|
||||
Sachi King
|
||||
Salvatore Rinchiera
|
||||
sandeepkiran-js
|
||||
Sander Van Balen
|
||||
Savio Jomton
|
||||
schlamar
|
||||
Scott Kitterman
|
||||
Sean
|
||||
seanj
|
||||
Sebastian Jordan
|
||||
Sebastian Schaetz
|
||||
Segev Finer
|
||||
SeongSoo Cho
|
||||
Sergey Vasilyev
|
||||
Seth Michael Larson
|
||||
Seth Woodworth
|
||||
Shahar Epstein
|
||||
Shantanu
|
||||
shireenrao
|
||||
Shivansh-007
|
||||
Shlomi Fish
|
||||
Shovan Maity
|
||||
Simeon Visser
|
||||
Simon Cross
|
||||
Simon Pichugin
|
||||
sinoroc
|
||||
sinscary
|
||||
snook92
|
||||
socketubs
|
||||
Sorin Sbarnea
|
||||
Srinivas Nyayapati
|
||||
Stavros Korokithakis
|
||||
Stefan Scherfke
|
||||
Stefano Rivera
|
||||
Stephan Erb
|
||||
Stephen Rosen
|
||||
stepshal
|
||||
Steve (Gadget) Barnes
|
||||
Steve Barnes
|
||||
Steve Dower
|
||||
Steve Kowalik
|
||||
Steven Myint
|
||||
Steven Silvester
|
||||
stonebig
|
||||
studioj
|
||||
Stéphane Bidoul
|
||||
Stéphane Bidoul (ACSONE)
|
||||
Stéphane Klein
|
||||
Sumana Harihareswara
|
||||
Surbhi Sharma
|
||||
Sviatoslav Sydorenko
|
||||
Swat009
|
||||
Sylvain
|
||||
Takayuki SHIMIZUKAWA
|
||||
Taneli Hukkinen
|
||||
tbeswick
|
||||
Thiago
|
||||
Thijs Triemstra
|
||||
Thomas Fenzl
|
||||
Thomas Grainger
|
||||
Thomas Guettler
|
||||
Thomas Johansson
|
||||
Thomas Kluyver
|
||||
Thomas Smith
|
||||
Thomas VINCENT
|
||||
Tim D. Smith
|
||||
Tim Gates
|
||||
Tim Harder
|
||||
Tim Heap
|
||||
tim smith
|
||||
tinruufu
|
||||
Tobias Hermann
|
||||
Tom Forbes
|
||||
Tom Freudenheim
|
||||
Tom V
|
||||
Tomas Hrnciar
|
||||
Tomas Orsava
|
||||
Tomer Chachamu
|
||||
Tommi Enenkel | AnB
|
||||
Tomáš Hrnčiar
|
||||
Tony Beswick
|
||||
Tony Narlock
|
||||
Tony Zhaocheng Tan
|
||||
TonyBeswick
|
||||
toonarmycaptain
|
||||
Toshio Kuratomi
|
||||
toxinu
|
||||
Travis Swicegood
|
||||
Tushar Sadhwani
|
||||
Tzu-ping Chung
|
||||
Valentin Haenel
|
||||
Victor Stinner
|
||||
victorvpaulo
|
||||
Vikram - Google
|
||||
Viktor Szépe
|
||||
Ville Skyttä
|
||||
Vinay Sajip
|
||||
Vincent Philippon
|
||||
Vinicyus Macedo
|
||||
Vipul Kumar
|
||||
Vitaly Babiy
|
||||
Vladimir Fokow
|
||||
Vladimir Rutsky
|
||||
W. Trevor King
|
||||
Wil Tan
|
||||
Wilfred Hughes
|
||||
William Edwards
|
||||
William ML Leslie
|
||||
William T Olson
|
||||
William Woodruff
|
||||
Wilson Mo
|
||||
wim glenn
|
||||
Winson Luk
|
||||
Wolfgang Maier
|
||||
Wu Zhenyu
|
||||
XAMES3
|
||||
Xavier Fernandez
|
||||
xoviat
|
||||
xtreak
|
||||
YAMAMOTO Takashi
|
||||
Yen Chi Hsuan
|
||||
Yeray Diaz Diaz
|
||||
Yoval P
|
||||
Yu Jian
|
||||
Yuan Jing Vincent Yan
|
||||
Yusuke Hayashi
|
||||
Zearin
|
||||
Zhiping Deng
|
||||
ziebam
|
||||
Zvezdan Petkovic
|
||||
Łukasz Langa
|
||||
Роман Донченко
|
||||
Семён Марьясин
|
||||
rekcäH nitraM
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
20
server/venv/Lib/site-packages/pip-24.0.dist-info/LICENSE.txt
Normal file
20
server/venv/Lib/site-packages/pip-24.0.dist-info/LICENSE.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
Copyright (c) 2008-present The pip developers (see AUTHORS.txt file)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
88
server/venv/Lib/site-packages/pip-24.0.dist-info/METADATA
Normal file
88
server/venv/Lib/site-packages/pip-24.0.dist-info/METADATA
Normal file
@@ -0,0 +1,88 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: pip
|
||||
Version: 24.0
|
||||
Summary: The PyPA recommended tool for installing Python packages.
|
||||
Author-email: The pip developers <distutils-sig@python.org>
|
||||
License: MIT
|
||||
Project-URL: Homepage, https://pip.pypa.io/
|
||||
Project-URL: Documentation, https://pip.pypa.io
|
||||
Project-URL: Source, https://github.com/pypa/pip
|
||||
Project-URL: Changelog, https://pip.pypa.io/en/stable/news/
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Topic :: Software Development :: Build Tools
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3 :: Only
|
||||
Classifier: Programming Language :: Python :: 3.7
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
||||
Requires-Python: >=3.7
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE.txt
|
||||
License-File: AUTHORS.txt
|
||||
|
||||
pip - The Python Package Installer
|
||||
==================================
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/pip.svg
|
||||
:target: https://pypi.org/project/pip/
|
||||
:alt: PyPI
|
||||
|
||||
.. image:: https://img.shields.io/pypi/pyversions/pip
|
||||
:target: https://pypi.org/project/pip
|
||||
:alt: PyPI - Python Version
|
||||
|
||||
.. image:: https://readthedocs.org/projects/pip/badge/?version=latest
|
||||
:target: https://pip.pypa.io/en/latest
|
||||
:alt: Documentation
|
||||
|
||||
pip is the `package installer`_ for Python. You can use pip to install packages from the `Python Package Index`_ and other indexes.
|
||||
|
||||
Please take a look at our documentation for how to install and use pip:
|
||||
|
||||
* `Installation`_
|
||||
* `Usage`_
|
||||
|
||||
We release updates regularly, with a new version every 3 months. Find more details in our documentation:
|
||||
|
||||
* `Release notes`_
|
||||
* `Release process`_
|
||||
|
||||
If you find bugs, need help, or want to talk to the developers, please use our mailing lists or chat rooms:
|
||||
|
||||
* `Issue tracking`_
|
||||
* `Discourse channel`_
|
||||
* `User IRC`_
|
||||
|
||||
If you want to get involved head over to GitHub to get the source code, look at our development documentation and feel free to jump on the developer mailing lists and chat rooms:
|
||||
|
||||
* `GitHub page`_
|
||||
* `Development documentation`_
|
||||
* `Development IRC`_
|
||||
|
||||
Code of Conduct
|
||||
---------------
|
||||
|
||||
Everyone interacting in the pip project's codebases, issue trackers, chat
|
||||
rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
|
||||
|
||||
.. _package installer: https://packaging.python.org/guides/tool-recommendations/
|
||||
.. _Python Package Index: https://pypi.org
|
||||
.. _Installation: https://pip.pypa.io/en/stable/installation/
|
||||
.. _Usage: https://pip.pypa.io/en/stable/
|
||||
.. _Release notes: https://pip.pypa.io/en/stable/news.html
|
||||
.. _Release process: https://pip.pypa.io/en/latest/development/release-process/
|
||||
.. _GitHub page: https://github.com/pypa/pip
|
||||
.. _Development documentation: https://pip.pypa.io/en/latest/development
|
||||
.. _Issue tracking: https://github.com/pypa/pip/issues
|
||||
.. _Discourse channel: https://discuss.python.org/c/packaging
|
||||
.. _User IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa
|
||||
.. _Development IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa-dev
|
||||
.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
|
||||
1024
server/venv/Lib/site-packages/pip-24.0.dist-info/RECORD
Normal file
1024
server/venv/Lib/site-packages/pip-24.0.dist-info/RECORD
Normal file
File diff suppressed because it is too large
Load Diff
5
server/venv/Lib/site-packages/pip-24.0.dist-info/WHEEL
Normal file
5
server/venv/Lib/site-packages/pip-24.0.dist-info/WHEEL
Normal file
@@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.42.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[console_scripts]
|
||||
pip = pip._internal.cli.main:main
|
||||
pip3 = pip._internal.cli.main:main
|
||||
pip3.10 = pip._internal.cli.main:main
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
13
server/venv/Lib/site-packages/pip/__init__.py
Normal file
13
server/venv/Lib/site-packages/pip/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import List, Optional
|
||||
|
||||
__version__ = "24.0"
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> int:
|
||||
"""This is an internal API only meant for use by pip's own console scripts.
|
||||
|
||||
For additional details, see https://github.com/pypa/pip/issues/7498.
|
||||
"""
|
||||
from pip._internal.utils.entrypoints import _wrapper
|
||||
|
||||
return _wrapper(args)
|
||||
24
server/venv/Lib/site-packages/pip/__main__.py
Normal file
24
server/venv/Lib/site-packages/pip/__main__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Remove '' and current working directory from the first entry
|
||||
# of sys.path, if present to avoid using current directory
|
||||
# in pip commands check, freeze, install, list and show,
|
||||
# when invoked as python -m pip <command>
|
||||
if sys.path[0] in ("", os.getcwd()):
|
||||
sys.path.pop(0)
|
||||
|
||||
# If we are running from a wheel, add the wheel to sys.path
|
||||
# This allows the usage python pip-*.whl/pip install pip-*.whl
|
||||
if __package__ == "":
|
||||
# __file__ is pip-*.whl/pip/__main__.py
|
||||
# first dirname call strips of '/__main__.py', second strips off '/pip'
|
||||
# Resulting path is the name of the wheel itself
|
||||
# Add that to sys.path so we can import pip
|
||||
path = os.path.dirname(os.path.dirname(__file__))
|
||||
sys.path.insert(0, path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pip._internal.cli.main import main as _main
|
||||
|
||||
sys.exit(_main())
|
||||
50
server/venv/Lib/site-packages/pip/__pip-runner__.py
Normal file
50
server/venv/Lib/site-packages/pip/__pip-runner__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Execute exactly this copy of pip, within a different environment.
|
||||
|
||||
This file is named as it is, to ensure that this module can't be imported via
|
||||
an import statement.
|
||||
"""
|
||||
|
||||
# /!\ This version compatibility check section must be Python 2 compatible. /!\
|
||||
|
||||
import sys
|
||||
|
||||
# Copied from setup.py
|
||||
PYTHON_REQUIRES = (3, 7)
|
||||
|
||||
|
||||
def version_str(version): # type: ignore
|
||||
return ".".join(str(v) for v in version)
|
||||
|
||||
|
||||
if sys.version_info[:2] < PYTHON_REQUIRES:
|
||||
raise SystemExit(
|
||||
"This version of pip does not support python {} (requires >={}).".format(
|
||||
version_str(sys.version_info[:2]), version_str(PYTHON_REQUIRES)
|
||||
)
|
||||
)
|
||||
|
||||
# From here on, we can use Python 3 features, but the syntax must remain
|
||||
# Python 2 compatible.
|
||||
|
||||
import runpy # noqa: E402
|
||||
from importlib.machinery import PathFinder # noqa: E402
|
||||
from os.path import dirname # noqa: E402
|
||||
|
||||
PIP_SOURCES_ROOT = dirname(dirname(__file__))
|
||||
|
||||
|
||||
class PipImportRedirectingFinder:
|
||||
@classmethod
|
||||
def find_spec(self, fullname, path=None, target=None): # type: ignore
|
||||
if fullname != "pip":
|
||||
return None
|
||||
|
||||
spec = PathFinder.find_spec(fullname, [PIP_SOURCES_ROOT], target)
|
||||
assert spec, (PIP_SOURCES_ROOT, fullname)
|
||||
return spec
|
||||
|
||||
|
||||
sys.meta_path.insert(0, PipImportRedirectingFinder())
|
||||
|
||||
assert __name__ == "__main__", "Cannot run __pip-runner__.py as a non-main module"
|
||||
runpy.run_module("pip", run_name="__main__", alter_sys=True)
|
||||
18
server/venv/Lib/site-packages/pip/_internal/__init__.py
Normal file
18
server/venv/Lib/site-packages/pip/_internal/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pip._internal.utils import _log
|
||||
|
||||
# init_logging() must be called before any call to logging.getLogger()
|
||||
# which happens at import of most modules.
|
||||
_log.init_logging()
|
||||
|
||||
|
||||
def main(args: (Optional[List[str]]) = None) -> int:
|
||||
"""This is preserved for old console scripts that may still be referencing
|
||||
it.
|
||||
|
||||
For additional details, see https://github.com/pypa/pip/issues/7498.
|
||||
"""
|
||||
from pip._internal.utils.entrypoints import _wrapper
|
||||
|
||||
return _wrapper(args)
|
||||
311
server/venv/Lib/site-packages/pip/_internal/build_env.py
Normal file
311
server/venv/Lib/site-packages/pip/_internal/build_env.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Build Environment used for isolation during sdist building
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import site
|
||||
import sys
|
||||
import textwrap
|
||||
from collections import OrderedDict
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple, Type, Union
|
||||
|
||||
from pip._vendor.certifi import where
|
||||
from pip._vendor.packaging.requirements import Requirement
|
||||
from pip._vendor.packaging.version import Version
|
||||
|
||||
from pip import __file__ as pip_location
|
||||
from pip._internal.cli.spinners import open_spinner
|
||||
from pip._internal.locations import get_platlib, get_purelib, get_scheme
|
||||
from pip._internal.metadata import get_default_environment, get_environment
|
||||
from pip._internal.utils.subprocess import call_subprocess
|
||||
from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pip._internal.index.package_finder import PackageFinder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dedup(a: str, b: str) -> Union[Tuple[str], Tuple[str, str]]:
|
||||
return (a, b) if a != b else (a,)
|
||||
|
||||
|
||||
class _Prefix:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
self.setup = False
|
||||
scheme = get_scheme("", prefix=path)
|
||||
self.bin_dir = scheme.scripts
|
||||
self.lib_dirs = _dedup(scheme.purelib, scheme.platlib)
|
||||
|
||||
|
||||
def get_runnable_pip() -> str:
|
||||
"""Get a file to pass to a Python executable, to run the currently-running pip.
|
||||
|
||||
This is used to run a pip subprocess, for installing requirements into the build
|
||||
environment.
|
||||
"""
|
||||
source = pathlib.Path(pip_location).resolve().parent
|
||||
|
||||
if not source.is_dir():
|
||||
# This would happen if someone is using pip from inside a zip file. In that
|
||||
# case, we can use that directly.
|
||||
return str(source)
|
||||
|
||||
return os.fsdecode(source / "__pip-runner__.py")
|
||||
|
||||
|
||||
def _get_system_sitepackages() -> Set[str]:
|
||||
"""Get system site packages
|
||||
|
||||
Usually from site.getsitepackages,
|
||||
but fallback on `get_purelib()/get_platlib()` if unavailable
|
||||
(e.g. in a virtualenv created by virtualenv<20)
|
||||
|
||||
Returns normalized set of strings.
|
||||
"""
|
||||
if hasattr(site, "getsitepackages"):
|
||||
system_sites = site.getsitepackages()
|
||||
else:
|
||||
# virtualenv < 20 overwrites site.py without getsitepackages
|
||||
# fallback on get_purelib/get_platlib.
|
||||
# this is known to miss things, but shouldn't in the cases
|
||||
# where getsitepackages() has been removed (inside a virtualenv)
|
||||
system_sites = [get_purelib(), get_platlib()]
|
||||
return {os.path.normcase(path) for path in system_sites}
|
||||
|
||||
|
||||
class BuildEnvironment:
|
||||
"""Creates and manages an isolated environment to install build deps"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
temp_dir = TempDirectory(kind=tempdir_kinds.BUILD_ENV, globally_managed=True)
|
||||
|
||||
self._prefixes = OrderedDict(
|
||||
(name, _Prefix(os.path.join(temp_dir.path, name)))
|
||||
for name in ("normal", "overlay")
|
||||
)
|
||||
|
||||
self._bin_dirs: List[str] = []
|
||||
self._lib_dirs: List[str] = []
|
||||
for prefix in reversed(list(self._prefixes.values())):
|
||||
self._bin_dirs.append(prefix.bin_dir)
|
||||
self._lib_dirs.extend(prefix.lib_dirs)
|
||||
|
||||
# Customize site to:
|
||||
# - ensure .pth files are honored
|
||||
# - prevent access to system site packages
|
||||
system_sites = _get_system_sitepackages()
|
||||
|
||||
self._site_dir = os.path.join(temp_dir.path, "site")
|
||||
if not os.path.exists(self._site_dir):
|
||||
os.mkdir(self._site_dir)
|
||||
with open(
|
||||
os.path.join(self._site_dir, "sitecustomize.py"), "w", encoding="utf-8"
|
||||
) as fp:
|
||||
fp.write(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import os, site, sys
|
||||
|
||||
# First, drop system-sites related paths.
|
||||
original_sys_path = sys.path[:]
|
||||
known_paths = set()
|
||||
for path in {system_sites!r}:
|
||||
site.addsitedir(path, known_paths=known_paths)
|
||||
system_paths = set(
|
||||
os.path.normcase(path)
|
||||
for path in sys.path[len(original_sys_path):]
|
||||
)
|
||||
original_sys_path = [
|
||||
path for path in original_sys_path
|
||||
if os.path.normcase(path) not in system_paths
|
||||
]
|
||||
sys.path = original_sys_path
|
||||
|
||||
# Second, add lib directories.
|
||||
# ensuring .pth file are processed.
|
||||
for path in {lib_dirs!r}:
|
||||
assert not path in sys.path
|
||||
site.addsitedir(path)
|
||||
"""
|
||||
).format(system_sites=system_sites, lib_dirs=self._lib_dirs)
|
||||
)
|
||||
|
||||
def __enter__(self) -> None:
|
||||
self._save_env = {
|
||||
name: os.environ.get(name, None)
|
||||
for name in ("PATH", "PYTHONNOUSERSITE", "PYTHONPATH")
|
||||
}
|
||||
|
||||
path = self._bin_dirs[:]
|
||||
old_path = self._save_env["PATH"]
|
||||
if old_path:
|
||||
path.extend(old_path.split(os.pathsep))
|
||||
|
||||
pythonpath = [self._site_dir]
|
||||
|
||||
os.environ.update(
|
||||
{
|
||||
"PATH": os.pathsep.join(path),
|
||||
"PYTHONNOUSERSITE": "1",
|
||||
"PYTHONPATH": os.pathsep.join(pythonpath),
|
||||
}
|
||||
)
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_val: Optional[BaseException],
|
||||
exc_tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
for varname, old_value in self._save_env.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(varname, None)
|
||||
else:
|
||||
os.environ[varname] = old_value
|
||||
|
||||
def check_requirements(
|
||||
self, reqs: Iterable[str]
|
||||
) -> Tuple[Set[Tuple[str, str]], Set[str]]:
|
||||
"""Return 2 sets:
|
||||
- conflicting requirements: set of (installed, wanted) reqs tuples
|
||||
- missing requirements: set of reqs
|
||||
"""
|
||||
missing = set()
|
||||
conflicting = set()
|
||||
if reqs:
|
||||
env = (
|
||||
get_environment(self._lib_dirs)
|
||||
if hasattr(self, "_lib_dirs")
|
||||
else get_default_environment()
|
||||
)
|
||||
for req_str in reqs:
|
||||
req = Requirement(req_str)
|
||||
# We're explicitly evaluating with an empty extra value, since build
|
||||
# environments are not provided any mechanism to select specific extras.
|
||||
if req.marker is not None and not req.marker.evaluate({"extra": ""}):
|
||||
continue
|
||||
dist = env.get_distribution(req.name)
|
||||
if not dist:
|
||||
missing.add(req_str)
|
||||
continue
|
||||
if isinstance(dist.version, Version):
|
||||
installed_req_str = f"{req.name}=={dist.version}"
|
||||
else:
|
||||
installed_req_str = f"{req.name}==={dist.version}"
|
||||
if not req.specifier.contains(dist.version, prereleases=True):
|
||||
conflicting.add((installed_req_str, req_str))
|
||||
# FIXME: Consider direct URL?
|
||||
return conflicting, missing
|
||||
|
||||
def install_requirements(
|
||||
self,
|
||||
finder: "PackageFinder",
|
||||
requirements: Iterable[str],
|
||||
prefix_as_string: str,
|
||||
*,
|
||||
kind: str,
|
||||
) -> None:
|
||||
prefix = self._prefixes[prefix_as_string]
|
||||
assert not prefix.setup
|
||||
prefix.setup = True
|
||||
if not requirements:
|
||||
return
|
||||
self._install_requirements(
|
||||
get_runnable_pip(),
|
||||
finder,
|
||||
requirements,
|
||||
prefix,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _install_requirements(
|
||||
pip_runnable: str,
|
||||
finder: "PackageFinder",
|
||||
requirements: Iterable[str],
|
||||
prefix: _Prefix,
|
||||
*,
|
||||
kind: str,
|
||||
) -> None:
|
||||
args: List[str] = [
|
||||
sys.executable,
|
||||
pip_runnable,
|
||||
"install",
|
||||
"--ignore-installed",
|
||||
"--no-user",
|
||||
"--prefix",
|
||||
prefix.path,
|
||||
"--no-warn-script-location",
|
||||
]
|
||||
if logger.getEffectiveLevel() <= logging.DEBUG:
|
||||
args.append("-v")
|
||||
for format_control in ("no_binary", "only_binary"):
|
||||
formats = getattr(finder.format_control, format_control)
|
||||
args.extend(
|
||||
(
|
||||
"--" + format_control.replace("_", "-"),
|
||||
",".join(sorted(formats or {":none:"})),
|
||||
)
|
||||
)
|
||||
|
||||
index_urls = finder.index_urls
|
||||
if index_urls:
|
||||
args.extend(["-i", index_urls[0]])
|
||||
for extra_index in index_urls[1:]:
|
||||
args.extend(["--extra-index-url", extra_index])
|
||||
else:
|
||||
args.append("--no-index")
|
||||
for link in finder.find_links:
|
||||
args.extend(["--find-links", link])
|
||||
|
||||
for host in finder.trusted_hosts:
|
||||
args.extend(["--trusted-host", host])
|
||||
if finder.allow_all_prereleases:
|
||||
args.append("--pre")
|
||||
if finder.prefer_binary:
|
||||
args.append("--prefer-binary")
|
||||
args.append("--")
|
||||
args.extend(requirements)
|
||||
extra_environ = {"_PIP_STANDALONE_CERT": where()}
|
||||
with open_spinner(f"Installing {kind}") as spinner:
|
||||
call_subprocess(
|
||||
args,
|
||||
command_desc=f"pip subprocess to install {kind}",
|
||||
spinner=spinner,
|
||||
extra_environ=extra_environ,
|
||||
)
|
||||
|
||||
|
||||
class NoOpBuildEnvironment(BuildEnvironment):
|
||||
"""A no-op drop-in replacement for BuildEnvironment"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> None:
|
||||
pass
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_val: Optional[BaseException],
|
||||
exc_tb: Optional[TracebackType],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def install_requirements(
|
||||
self,
|
||||
finder: "PackageFinder",
|
||||
requirements: Iterable[str],
|
||||
prefix_as_string: str,
|
||||
*,
|
||||
kind: str,
|
||||
) -> None:
|
||||
raise NotImplementedError()
|
||||
290
server/venv/Lib/site-packages/pip/_internal/cache.py
Normal file
290
server/venv/Lib/site-packages/pip/_internal/cache.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Cache Management
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pip._vendor.packaging.tags import Tag, interpreter_name, interpreter_version
|
||||
from pip._vendor.packaging.utils import canonicalize_name
|
||||
|
||||
from pip._internal.exceptions import InvalidWheelFilename
|
||||
from pip._internal.models.direct_url import DirectUrl
|
||||
from pip._internal.models.link import Link
|
||||
from pip._internal.models.wheel import Wheel
|
||||
from pip._internal.utils.temp_dir import TempDirectory, tempdir_kinds
|
||||
from pip._internal.utils.urls import path_to_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ORIGIN_JSON_NAME = "origin.json"
|
||||
|
||||
|
||||
def _hash_dict(d: Dict[str, str]) -> str:
|
||||
"""Return a stable sha224 of a dictionary."""
|
||||
s = json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
return hashlib.sha224(s.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
class Cache:
|
||||
"""An abstract class - provides cache directories for data from links
|
||||
|
||||
:param cache_dir: The root of the cache.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: str) -> None:
|
||||
super().__init__()
|
||||
assert not cache_dir or os.path.isabs(cache_dir)
|
||||
self.cache_dir = cache_dir or None
|
||||
|
||||
def _get_cache_path_parts(self, link: Link) -> List[str]:
|
||||
"""Get parts of part that must be os.path.joined with cache_dir"""
|
||||
|
||||
# We want to generate an url to use as our cache key, we don't want to
|
||||
# just re-use the URL because it might have other items in the fragment
|
||||
# and we don't care about those.
|
||||
key_parts = {"url": link.url_without_fragment}
|
||||
if link.hash_name is not None and link.hash is not None:
|
||||
key_parts[link.hash_name] = link.hash
|
||||
if link.subdirectory_fragment:
|
||||
key_parts["subdirectory"] = link.subdirectory_fragment
|
||||
|
||||
# Include interpreter name, major and minor version in cache key
|
||||
# to cope with ill-behaved sdists that build a different wheel
|
||||
# depending on the python version their setup.py is being run on,
|
||||
# and don't encode the difference in compatibility tags.
|
||||
# https://github.com/pypa/pip/issues/7296
|
||||
key_parts["interpreter_name"] = interpreter_name()
|
||||
key_parts["interpreter_version"] = interpreter_version()
|
||||
|
||||
# Encode our key url with sha224, we'll use this because it has similar
|
||||
# security properties to sha256, but with a shorter total output (and
|
||||
# thus less secure). However the differences don't make a lot of
|
||||
# difference for our use case here.
|
||||
hashed = _hash_dict(key_parts)
|
||||
|
||||
# We want to nest the directories some to prevent having a ton of top
|
||||
# level directories where we might run out of sub directories on some
|
||||
# FS.
|
||||
parts = [hashed[:2], hashed[2:4], hashed[4:6], hashed[6:]]
|
||||
|
||||
return parts
|
||||
|
||||
def _get_candidates(self, link: Link, canonical_package_name: str) -> List[Any]:
|
||||
can_not_cache = not self.cache_dir or not canonical_package_name or not link
|
||||
if can_not_cache:
|
||||
return []
|
||||
|
||||
path = self.get_path_for_link(link)
|
||||
if os.path.isdir(path):
|
||||
return [(candidate, path) for candidate in os.listdir(path)]
|
||||
return []
|
||||
|
||||
def get_path_for_link(self, link: Link) -> str:
|
||||
"""Return a directory to store cached items in for link."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get(
|
||||
self,
|
||||
link: Link,
|
||||
package_name: Optional[str],
|
||||
supported_tags: List[Tag],
|
||||
) -> Link:
|
||||
"""Returns a link to a cached item if it exists, otherwise returns the
|
||||
passed link.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SimpleWheelCache(Cache):
|
||||
"""A cache of wheels for future installs."""
|
||||
|
||||
def __init__(self, cache_dir: str) -> None:
|
||||
super().__init__(cache_dir)
|
||||
|
||||
def get_path_for_link(self, link: Link) -> str:
|
||||
"""Return a directory to store cached wheels for link
|
||||
|
||||
Because there are M wheels for any one sdist, we provide a directory
|
||||
to cache them in, and then consult that directory when looking up
|
||||
cache hits.
|
||||
|
||||
We only insert things into the cache if they have plausible version
|
||||
numbers, so that we don't contaminate the cache with things that were
|
||||
not unique. E.g. ./package might have dozens of installs done for it
|
||||
and build a version of 0.0...and if we built and cached a wheel, we'd
|
||||
end up using the same wheel even if the source has been edited.
|
||||
|
||||
:param link: The link of the sdist for which this will cache wheels.
|
||||
"""
|
||||
parts = self._get_cache_path_parts(link)
|
||||
assert self.cache_dir
|
||||
# Store wheels within the root cache_dir
|
||||
return os.path.join(self.cache_dir, "wheels", *parts)
|
||||
|
||||
def get(
|
||||
self,
|
||||
link: Link,
|
||||
package_name: Optional[str],
|
||||
supported_tags: List[Tag],
|
||||
) -> Link:
|
||||
candidates = []
|
||||
|
||||
if not package_name:
|
||||
return link
|
||||
|
||||
canonical_package_name = canonicalize_name(package_name)
|
||||
for wheel_name, wheel_dir in self._get_candidates(link, canonical_package_name):
|
||||
try:
|
||||
wheel = Wheel(wheel_name)
|
||||
except InvalidWheelFilename:
|
||||
continue
|
||||
if canonicalize_name(wheel.name) != canonical_package_name:
|
||||
logger.debug(
|
||||
"Ignoring cached wheel %s for %s as it "
|
||||
"does not match the expected distribution name %s.",
|
||||
wheel_name,
|
||||
link,
|
||||
package_name,
|
||||
)
|
||||
continue
|
||||
if not wheel.supported(supported_tags):
|
||||
# Built for a different python/arch/etc
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
wheel.support_index_min(supported_tags),
|
||||
wheel_name,
|
||||
wheel_dir,
|
||||
)
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return link
|
||||
|
||||
_, wheel_name, wheel_dir = min(candidates)
|
||||
return Link(path_to_url(os.path.join(wheel_dir, wheel_name)))
|
||||
|
||||
|
||||
class EphemWheelCache(SimpleWheelCache):
|
||||
"""A SimpleWheelCache that creates it's own temporary cache directory"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._temp_dir = TempDirectory(
|
||||
kind=tempdir_kinds.EPHEM_WHEEL_CACHE,
|
||||
globally_managed=True,
|
||||
)
|
||||
|
||||
super().__init__(self._temp_dir.path)
|
||||
|
||||
|
||||
class CacheEntry:
|
||||
def __init__(
|
||||
self,
|
||||
link: Link,
|
||||
persistent: bool,
|
||||
):
|
||||
self.link = link
|
||||
self.persistent = persistent
|
||||
self.origin: Optional[DirectUrl] = None
|
||||
origin_direct_url_path = Path(self.link.file_path).parent / ORIGIN_JSON_NAME
|
||||
if origin_direct_url_path.exists():
|
||||
try:
|
||||
self.origin = DirectUrl.from_json(
|
||||
origin_direct_url_path.read_text(encoding="utf-8")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Ignoring invalid cache entry origin file %s for %s (%s)",
|
||||
origin_direct_url_path,
|
||||
link.filename,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
class WheelCache(Cache):
|
||||
"""Wraps EphemWheelCache and SimpleWheelCache into a single Cache
|
||||
|
||||
This Cache allows for gracefully degradation, using the ephem wheel cache
|
||||
when a certain link is not found in the simple wheel cache first.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: str) -> None:
|
||||
super().__init__(cache_dir)
|
||||
self._wheel_cache = SimpleWheelCache(cache_dir)
|
||||
self._ephem_cache = EphemWheelCache()
|
||||
|
||||
def get_path_for_link(self, link: Link) -> str:
|
||||
return self._wheel_cache.get_path_for_link(link)
|
||||
|
||||
def get_ephem_path_for_link(self, link: Link) -> str:
|
||||
return self._ephem_cache.get_path_for_link(link)
|
||||
|
||||
def get(
|
||||
self,
|
||||
link: Link,
|
||||
package_name: Optional[str],
|
||||
supported_tags: List[Tag],
|
||||
) -> Link:
|
||||
cache_entry = self.get_cache_entry(link, package_name, supported_tags)
|
||||
if cache_entry is None:
|
||||
return link
|
||||
return cache_entry.link
|
||||
|
||||
def get_cache_entry(
|
||||
self,
|
||||
link: Link,
|
||||
package_name: Optional[str],
|
||||
supported_tags: List[Tag],
|
||||
) -> Optional[CacheEntry]:
|
||||
"""Returns a CacheEntry with a link to a cached item if it exists or
|
||||
None. The cache entry indicates if the item was found in the persistent
|
||||
or ephemeral cache.
|
||||
"""
|
||||
retval = self._wheel_cache.get(
|
||||
link=link,
|
||||
package_name=package_name,
|
||||
supported_tags=supported_tags,
|
||||
)
|
||||
if retval is not link:
|
||||
return CacheEntry(retval, persistent=True)
|
||||
|
||||
retval = self._ephem_cache.get(
|
||||
link=link,
|
||||
package_name=package_name,
|
||||
supported_tags=supported_tags,
|
||||
)
|
||||
if retval is not link:
|
||||
return CacheEntry(retval, persistent=False)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def record_download_origin(cache_dir: str, download_info: DirectUrl) -> None:
|
||||
origin_path = Path(cache_dir) / ORIGIN_JSON_NAME
|
||||
if origin_path.exists():
|
||||
try:
|
||||
origin = DirectUrl.from_json(origin_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read origin file %s in cache entry (%s). "
|
||||
"Will attempt to overwrite it.",
|
||||
origin_path,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
# TODO: use DirectUrl.equivalent when
|
||||
# https://github.com/pypa/pip/pull/10564 is merged.
|
||||
if origin.url != download_info.url:
|
||||
logger.warning(
|
||||
"Origin URL %s in cache entry %s does not match download URL "
|
||||
"%s. This is likely a pip bug or a cache corruption issue. "
|
||||
"Will overwrite it with the new value.",
|
||||
origin.url,
|
||||
cache_dir,
|
||||
download_info.url,
|
||||
)
|
||||
origin_path.write_text(download_info.to_json(), encoding="utf-8")
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Subpackage containing all of pip's command line interface related code
|
||||
"""
|
||||
|
||||
# This file intentionally does not import submodules
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Logic that powers autocompletion installed by ``pip completion``.
|
||||
"""
|
||||
|
||||
import optparse
|
||||
import os
|
||||
import sys
|
||||
from itertools import chain
|
||||
from typing import Any, Iterable, List, Optional
|
||||
|
||||
from pip._internal.cli.main_parser import create_main_parser
|
||||
from pip._internal.commands import commands_dict, create_command
|
||||
from pip._internal.metadata import get_default_environment
|
||||
|
||||
|
||||
def autocomplete() -> None:
|
||||
"""Entry Point for completion of main and subcommand options."""
|
||||
# Don't complete if user hasn't sourced bash_completion file.
|
||||
if "PIP_AUTO_COMPLETE" not in os.environ:
|
||||
return
|
||||
cwords = os.environ["COMP_WORDS"].split()[1:]
|
||||
cword = int(os.environ["COMP_CWORD"])
|
||||
try:
|
||||
current = cwords[cword - 1]
|
||||
except IndexError:
|
||||
current = ""
|
||||
|
||||
parser = create_main_parser()
|
||||
subcommands = list(commands_dict)
|
||||
options = []
|
||||
|
||||
# subcommand
|
||||
subcommand_name: Optional[str] = None
|
||||
for word in cwords:
|
||||
if word in subcommands:
|
||||
subcommand_name = word
|
||||
break
|
||||
# subcommand options
|
||||
if subcommand_name is not None:
|
||||
# special case: 'help' subcommand has no options
|
||||
if subcommand_name == "help":
|
||||
sys.exit(1)
|
||||
# special case: list locally installed dists for show and uninstall
|
||||
should_list_installed = not current.startswith("-") and subcommand_name in [
|
||||
"show",
|
||||
"uninstall",
|
||||
]
|
||||
if should_list_installed:
|
||||
env = get_default_environment()
|
||||
lc = current.lower()
|
||||
installed = [
|
||||
dist.canonical_name
|
||||
for dist in env.iter_installed_distributions(local_only=True)
|
||||
if dist.canonical_name.startswith(lc)
|
||||
and dist.canonical_name not in cwords[1:]
|
||||
]
|
||||
# if there are no dists installed, fall back to option completion
|
||||
if installed:
|
||||
for dist in installed:
|
||||
print(dist)
|
||||
sys.exit(1)
|
||||
|
||||
should_list_installables = (
|
||||
not current.startswith("-") and subcommand_name == "install"
|
||||
)
|
||||
if should_list_installables:
|
||||
for path in auto_complete_paths(current, "path"):
|
||||
print(path)
|
||||
sys.exit(1)
|
||||
|
||||
subcommand = create_command(subcommand_name)
|
||||
|
||||
for opt in subcommand.parser.option_list_all:
|
||||
if opt.help != optparse.SUPPRESS_HELP:
|
||||
options += [
|
||||
(opt_str, opt.nargs) for opt_str in opt._long_opts + opt._short_opts
|
||||
]
|
||||
|
||||
# filter out previously specified options from available options
|
||||
prev_opts = [x.split("=")[0] for x in cwords[1 : cword - 1]]
|
||||
options = [(x, v) for (x, v) in options if x not in prev_opts]
|
||||
# filter options by current input
|
||||
options = [(k, v) for k, v in options if k.startswith(current)]
|
||||
# get completion type given cwords and available subcommand options
|
||||
completion_type = get_path_completion_type(
|
||||
cwords,
|
||||
cword,
|
||||
subcommand.parser.option_list_all,
|
||||
)
|
||||
# get completion files and directories if ``completion_type`` is
|
||||
# ``<file>``, ``<dir>`` or ``<path>``
|
||||
if completion_type:
|
||||
paths = auto_complete_paths(current, completion_type)
|
||||
options = [(path, 0) for path in paths]
|
||||
for option in options:
|
||||
opt_label = option[0]
|
||||
# append '=' to options which require args
|
||||
if option[1] and option[0][:2] == "--":
|
||||
opt_label += "="
|
||||
print(opt_label)
|
||||
else:
|
||||
# show main parser options only when necessary
|
||||
|
||||
opts = [i.option_list for i in parser.option_groups]
|
||||
opts.append(parser.option_list)
|
||||
flattened_opts = chain.from_iterable(opts)
|
||||
if current.startswith("-"):
|
||||
for opt in flattened_opts:
|
||||
if opt.help != optparse.SUPPRESS_HELP:
|
||||
subcommands += opt._long_opts + opt._short_opts
|
||||
else:
|
||||
# get completion type given cwords and all available options
|
||||
completion_type = get_path_completion_type(cwords, cword, flattened_opts)
|
||||
if completion_type:
|
||||
subcommands = list(auto_complete_paths(current, completion_type))
|
||||
|
||||
print(" ".join([x for x in subcommands if x.startswith(current)]))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_path_completion_type(
|
||||
cwords: List[str], cword: int, opts: Iterable[Any]
|
||||
) -> Optional[str]:
|
||||
"""Get the type of path completion (``file``, ``dir``, ``path`` or None)
|
||||
|
||||
:param cwords: same as the environmental variable ``COMP_WORDS``
|
||||
:param cword: same as the environmental variable ``COMP_CWORD``
|
||||
:param opts: The available options to check
|
||||
:return: path completion type (``file``, ``dir``, ``path`` or None)
|
||||
"""
|
||||
if cword < 2 or not cwords[cword - 2].startswith("-"):
|
||||
return None
|
||||
for opt in opts:
|
||||
if opt.help == optparse.SUPPRESS_HELP:
|
||||
continue
|
||||
for o in str(opt).split("/"):
|
||||
if cwords[cword - 2].split("=")[0] == o:
|
||||
if not opt.metavar or any(
|
||||
x in ("path", "file", "dir") for x in opt.metavar.split("/")
|
||||
):
|
||||
return opt.metavar
|
||||
return None
|
||||
|
||||
|
||||
def auto_complete_paths(current: str, completion_type: str) -> Iterable[str]:
|
||||
"""If ``completion_type`` is ``file`` or ``path``, list all regular files
|
||||
and directories starting with ``current``; otherwise only list directories
|
||||
starting with ``current``.
|
||||
|
||||
:param current: The word to be completed
|
||||
:param completion_type: path completion type(``file``, ``path`` or ``dir``)
|
||||
:return: A generator of regular files and/or directories
|
||||
"""
|
||||
directory, filename = os.path.split(current)
|
||||
current_path = os.path.abspath(directory)
|
||||
# Don't complete paths if they can't be accessed
|
||||
if not os.access(current_path, os.R_OK):
|
||||
return
|
||||
filename = os.path.normcase(filename)
|
||||
# list all files that start with ``filename``
|
||||
file_list = (
|
||||
x for x in os.listdir(current_path) if os.path.normcase(x).startswith(filename)
|
||||
)
|
||||
for f in file_list:
|
||||
opt = os.path.join(current_path, f)
|
||||
comp_file = os.path.normcase(os.path.join(directory, f))
|
||||
# complete regular files when there is not ``<dir>`` after option
|
||||
# complete directories when there is ``<file>``, ``<path>`` or
|
||||
# ``<dir>``after option
|
||||
if completion_type != "dir" and os.path.isfile(opt):
|
||||
yield comp_file
|
||||
elif os.path.isdir(opt):
|
||||
yield os.path.join(comp_file, "")
|
||||
236
server/venv/Lib/site-packages/pip/_internal/cli/base_command.py
Normal file
236
server/venv/Lib/site-packages/pip/_internal/cli/base_command.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Base Command class, and related routines"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import logging.config
|
||||
import optparse
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from optparse import Values
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from pip._vendor.rich import traceback as rich_traceback
|
||||
|
||||
from pip._internal.cli import cmdoptions
|
||||
from pip._internal.cli.command_context import CommandContextMixIn
|
||||
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
|
||||
from pip._internal.cli.status_codes import (
|
||||
ERROR,
|
||||
PREVIOUS_BUILD_DIR_ERROR,
|
||||
UNKNOWN_ERROR,
|
||||
VIRTUALENV_NOT_FOUND,
|
||||
)
|
||||
from pip._internal.exceptions import (
|
||||
BadCommand,
|
||||
CommandError,
|
||||
DiagnosticPipError,
|
||||
InstallationError,
|
||||
NetworkConnectionError,
|
||||
PreviousBuildDirError,
|
||||
UninstallationError,
|
||||
)
|
||||
from pip._internal.utils.filesystem import check_path_owner
|
||||
from pip._internal.utils.logging import BrokenStdoutLoggingError, setup_logging
|
||||
from pip._internal.utils.misc import get_prog, normalize_path
|
||||
from pip._internal.utils.temp_dir import TempDirectoryTypeRegistry as TempDirRegistry
|
||||
from pip._internal.utils.temp_dir import global_tempdir_manager, tempdir_registry
|
||||
from pip._internal.utils.virtualenv import running_under_virtualenv
|
||||
|
||||
__all__ = ["Command"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(CommandContextMixIn):
|
||||
usage: str = ""
|
||||
ignore_require_venv: bool = False
|
||||
|
||||
def __init__(self, name: str, summary: str, isolated: bool = False) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.name = name
|
||||
self.summary = summary
|
||||
self.parser = ConfigOptionParser(
|
||||
usage=self.usage,
|
||||
prog=f"{get_prog()} {name}",
|
||||
formatter=UpdatingDefaultsHelpFormatter(),
|
||||
add_help_option=False,
|
||||
name=name,
|
||||
description=self.__doc__,
|
||||
isolated=isolated,
|
||||
)
|
||||
|
||||
self.tempdir_registry: Optional[TempDirRegistry] = None
|
||||
|
||||
# Commands should add options to this option group
|
||||
optgroup_name = f"{self.name.capitalize()} Options"
|
||||
self.cmd_opts = optparse.OptionGroup(self.parser, optgroup_name)
|
||||
|
||||
# Add the general options
|
||||
gen_opts = cmdoptions.make_option_group(
|
||||
cmdoptions.general_group,
|
||||
self.parser,
|
||||
)
|
||||
self.parser.add_option_group(gen_opts)
|
||||
|
||||
self.add_options()
|
||||
|
||||
def add_options(self) -> None:
|
||||
pass
|
||||
|
||||
def handle_pip_version_check(self, options: Values) -> None:
|
||||
"""
|
||||
This is a no-op so that commands by default do not do the pip version
|
||||
check.
|
||||
"""
|
||||
# Make sure we do the pip version check if the index_group options
|
||||
# are present.
|
||||
assert not hasattr(options, "no_index")
|
||||
|
||||
def run(self, options: Values, args: List[str]) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_args(self, args: List[str]) -> Tuple[Values, List[str]]:
|
||||
# factored out for testability
|
||||
return self.parser.parse_args(args)
|
||||
|
||||
def main(self, args: List[str]) -> int:
|
||||
try:
|
||||
with self.main_context():
|
||||
return self._main(args)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
|
||||
def _main(self, args: List[str]) -> int:
|
||||
# We must initialize this before the tempdir manager, otherwise the
|
||||
# configuration would not be accessible by the time we clean up the
|
||||
# tempdir manager.
|
||||
self.tempdir_registry = self.enter_context(tempdir_registry())
|
||||
# Intentionally set as early as possible so globally-managed temporary
|
||||
# directories are available to the rest of the code.
|
||||
self.enter_context(global_tempdir_manager())
|
||||
|
||||
options, args = self.parse_args(args)
|
||||
|
||||
# Set verbosity so that it can be used elsewhere.
|
||||
self.verbosity = options.verbose - options.quiet
|
||||
|
||||
level_number = setup_logging(
|
||||
verbosity=self.verbosity,
|
||||
no_color=options.no_color,
|
||||
user_log_file=options.log,
|
||||
)
|
||||
|
||||
always_enabled_features = set(options.features_enabled) & set(
|
||||
cmdoptions.ALWAYS_ENABLED_FEATURES
|
||||
)
|
||||
if always_enabled_features:
|
||||
logger.warning(
|
||||
"The following features are always enabled: %s. ",
|
||||
", ".join(sorted(always_enabled_features)),
|
||||
)
|
||||
|
||||
# Make sure that the --python argument isn't specified after the
|
||||
# subcommand. We can tell, because if --python was specified,
|
||||
# we should only reach this point if we're running in the created
|
||||
# subprocess, which has the _PIP_RUNNING_IN_SUBPROCESS environment
|
||||
# variable set.
|
||||
if options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ:
|
||||
logger.critical(
|
||||
"The --python option must be placed before the pip subcommand name"
|
||||
)
|
||||
sys.exit(ERROR)
|
||||
|
||||
# TODO: Try to get these passing down from the command?
|
||||
# without resorting to os.environ to hold these.
|
||||
# This also affects isolated builds and it should.
|
||||
|
||||
if options.no_input:
|
||||
os.environ["PIP_NO_INPUT"] = "1"
|
||||
|
||||
if options.exists_action:
|
||||
os.environ["PIP_EXISTS_ACTION"] = " ".join(options.exists_action)
|
||||
|
||||
if options.require_venv and not self.ignore_require_venv:
|
||||
# If a venv is required check if it can really be found
|
||||
if not running_under_virtualenv():
|
||||
logger.critical("Could not find an activated virtualenv (required).")
|
||||
sys.exit(VIRTUALENV_NOT_FOUND)
|
||||
|
||||
if options.cache_dir:
|
||||
options.cache_dir = normalize_path(options.cache_dir)
|
||||
if not check_path_owner(options.cache_dir):
|
||||
logger.warning(
|
||||
"The directory '%s' or its parent directory is not owned "
|
||||
"or is not writable by the current user. The cache "
|
||||
"has been disabled. Check the permissions and owner of "
|
||||
"that directory. If executing pip with sudo, you should "
|
||||
"use sudo's -H flag.",
|
||||
options.cache_dir,
|
||||
)
|
||||
options.cache_dir = None
|
||||
|
||||
def intercepts_unhandled_exc(
|
||||
run_func: Callable[..., int]
|
||||
) -> Callable[..., int]:
|
||||
@functools.wraps(run_func)
|
||||
def exc_logging_wrapper(*args: Any) -> int:
|
||||
try:
|
||||
status = run_func(*args)
|
||||
assert isinstance(status, int)
|
||||
return status
|
||||
except DiagnosticPipError as exc:
|
||||
logger.error("%s", exc, extra={"rich": True})
|
||||
logger.debug("Exception information:", exc_info=True)
|
||||
|
||||
return ERROR
|
||||
except PreviousBuildDirError as exc:
|
||||
logger.critical(str(exc))
|
||||
logger.debug("Exception information:", exc_info=True)
|
||||
|
||||
return PREVIOUS_BUILD_DIR_ERROR
|
||||
except (
|
||||
InstallationError,
|
||||
UninstallationError,
|
||||
BadCommand,
|
||||
NetworkConnectionError,
|
||||
) as exc:
|
||||
logger.critical(str(exc))
|
||||
logger.debug("Exception information:", exc_info=True)
|
||||
|
||||
return ERROR
|
||||
except CommandError as exc:
|
||||
logger.critical("%s", exc)
|
||||
logger.debug("Exception information:", exc_info=True)
|
||||
|
||||
return ERROR
|
||||
except BrokenStdoutLoggingError:
|
||||
# Bypass our logger and write any remaining messages to
|
||||
# stderr because stdout no longer works.
|
||||
print("ERROR: Pipe to stdout was broken", file=sys.stderr)
|
||||
if level_number <= logging.DEBUG:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
||||
return ERROR
|
||||
except KeyboardInterrupt:
|
||||
logger.critical("Operation cancelled by user")
|
||||
logger.debug("Exception information:", exc_info=True)
|
||||
|
||||
return ERROR
|
||||
except BaseException:
|
||||
logger.critical("Exception:", exc_info=True)
|
||||
|
||||
return UNKNOWN_ERROR
|
||||
|
||||
return exc_logging_wrapper
|
||||
|
||||
try:
|
||||
if not options.debug_mode:
|
||||
run = intercepts_unhandled_exc(self.run)
|
||||
else:
|
||||
run = self.run
|
||||
rich_traceback.install(show_locals=True)
|
||||
return run(options, args)
|
||||
finally:
|
||||
self.handle_pip_version_check(options)
|
||||
1074
server/venv/Lib/site-packages/pip/_internal/cli/cmdoptions.py
Normal file
1074
server/venv/Lib/site-packages/pip/_internal/cli/cmdoptions.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import ContextManager, Generator, TypeVar
|
||||
|
||||
_T = TypeVar("_T", covariant=True)
|
||||
|
||||
|
||||
class CommandContextMixIn:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._in_main_context = False
|
||||
self._main_context = ExitStack()
|
||||
|
||||
@contextmanager
|
||||
def main_context(self) -> Generator[None, None, None]:
|
||||
assert not self._in_main_context
|
||||
|
||||
self._in_main_context = True
|
||||
try:
|
||||
with self._main_context:
|
||||
yield
|
||||
finally:
|
||||
self._in_main_context = False
|
||||
|
||||
def enter_context(self, context_provider: ContextManager[_T]) -> _T:
|
||||
assert self._in_main_context
|
||||
|
||||
return self._main_context.enter_context(context_provider)
|
||||
79
server/venv/Lib/site-packages/pip/_internal/cli/main.py
Normal file
79
server/venv/Lib/site-packages/pip/_internal/cli/main.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Primary application entrypoint.
|
||||
"""
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import List, Optional
|
||||
|
||||
from pip._internal.cli.autocompletion import autocomplete
|
||||
from pip._internal.cli.main_parser import parse_command
|
||||
from pip._internal.commands import create_command
|
||||
from pip._internal.exceptions import PipError
|
||||
from pip._internal.utils import deprecation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Do not import and use main() directly! Using it directly is actively
|
||||
# discouraged by pip's maintainers. The name, location and behavior of
|
||||
# this function is subject to change, so calling it directly is not
|
||||
# portable across different pip versions.
|
||||
|
||||
# In addition, running pip in-process is unsupported and unsafe. This is
|
||||
# elaborated in detail at
|
||||
# https://pip.pypa.io/en/stable/user_guide/#using-pip-from-your-program.
|
||||
# That document also provides suggestions that should work for nearly
|
||||
# all users that are considering importing and using main() directly.
|
||||
|
||||
# However, we know that certain users will still want to invoke pip
|
||||
# in-process. If you understand and accept the implications of using pip
|
||||
# in an unsupported manner, the best approach is to use runpy to avoid
|
||||
# depending on the exact location of this entry point.
|
||||
|
||||
# The following example shows how to use runpy to invoke pip in that
|
||||
# case:
|
||||
#
|
||||
# sys.argv = ["pip", your, args, here]
|
||||
# runpy.run_module("pip", run_name="__main__")
|
||||
#
|
||||
# Note that this will exit the process after running, unlike a direct
|
||||
# call to main. As it is not safe to do any processing after calling
|
||||
# main, this should not be an issue in practice.
|
||||
|
||||
|
||||
def main(args: Optional[List[str]] = None) -> int:
|
||||
if args is None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Suppress the pkg_resources deprecation warning
|
||||
# Note - we use a module of .*pkg_resources to cover
|
||||
# the normal case (pip._vendor.pkg_resources) and the
|
||||
# devendored case (a bare pkg_resources)
|
||||
warnings.filterwarnings(
|
||||
action="ignore", category=DeprecationWarning, module=".*pkg_resources"
|
||||
)
|
||||
|
||||
# Configure our deprecation warnings to be sent through loggers
|
||||
deprecation.install_warning_logger()
|
||||
|
||||
autocomplete()
|
||||
|
||||
try:
|
||||
cmd_name, cmd_args = parse_command(args)
|
||||
except PipError as exc:
|
||||
sys.stderr.write(f"ERROR: {exc}")
|
||||
sys.stderr.write(os.linesep)
|
||||
sys.exit(1)
|
||||
|
||||
# Needed for locale.getpreferredencoding(False) to work
|
||||
# in pip._internal.utils.encoding.auto_decode
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
except locale.Error as e:
|
||||
# setlocale can apparently crash if locale are uninitialized
|
||||
logger.debug("Ignoring error %s when setting locale", e)
|
||||
command = create_command(cmd_name, isolated=("--isolated" in cmd_args))
|
||||
|
||||
return command.main(cmd_args)
|
||||
134
server/venv/Lib/site-packages/pip/_internal/cli/main_parser.py
Normal file
134
server/venv/Lib/site-packages/pip/_internal/cli/main_parser.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""A single place for constructing and exposing the main parser
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from pip._internal.build_env import get_runnable_pip
|
||||
from pip._internal.cli import cmdoptions
|
||||
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
|
||||
from pip._internal.commands import commands_dict, get_similar_commands
|
||||
from pip._internal.exceptions import CommandError
|
||||
from pip._internal.utils.misc import get_pip_version, get_prog
|
||||
|
||||
__all__ = ["create_main_parser", "parse_command"]
|
||||
|
||||
|
||||
def create_main_parser() -> ConfigOptionParser:
|
||||
"""Creates and returns the main parser for pip's CLI"""
|
||||
|
||||
parser = ConfigOptionParser(
|
||||
usage="\n%prog <command> [options]",
|
||||
add_help_option=False,
|
||||
formatter=UpdatingDefaultsHelpFormatter(),
|
||||
name="global",
|
||||
prog=get_prog(),
|
||||
)
|
||||
parser.disable_interspersed_args()
|
||||
|
||||
parser.version = get_pip_version()
|
||||
|
||||
# add the general options
|
||||
gen_opts = cmdoptions.make_option_group(cmdoptions.general_group, parser)
|
||||
parser.add_option_group(gen_opts)
|
||||
|
||||
# so the help formatter knows
|
||||
parser.main = True # type: ignore
|
||||
|
||||
# create command listing for description
|
||||
description = [""] + [
|
||||
f"{name:27} {command_info.summary}"
|
||||
for name, command_info in commands_dict.items()
|
||||
]
|
||||
parser.description = "\n".join(description)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def identify_python_interpreter(python: str) -> Optional[str]:
|
||||
# If the named file exists, use it.
|
||||
# If it's a directory, assume it's a virtual environment and
|
||||
# look for the environment's Python executable.
|
||||
if os.path.exists(python):
|
||||
if os.path.isdir(python):
|
||||
# bin/python for Unix, Scripts/python.exe for Windows
|
||||
# Try both in case of odd cases like cygwin.
|
||||
for exe in ("bin/python", "Scripts/python.exe"):
|
||||
py = os.path.join(python, exe)
|
||||
if os.path.exists(py):
|
||||
return py
|
||||
else:
|
||||
return python
|
||||
|
||||
# Could not find the interpreter specified
|
||||
return None
|
||||
|
||||
|
||||
def parse_command(args: List[str]) -> Tuple[str, List[str]]:
|
||||
parser = create_main_parser()
|
||||
|
||||
# Note: parser calls disable_interspersed_args(), so the result of this
|
||||
# call is to split the initial args into the general options before the
|
||||
# subcommand and everything else.
|
||||
# For example:
|
||||
# args: ['--timeout=5', 'install', '--user', 'INITools']
|
||||
# general_options: ['--timeout==5']
|
||||
# args_else: ['install', '--user', 'INITools']
|
||||
general_options, args_else = parser.parse_args(args)
|
||||
|
||||
# --python
|
||||
if general_options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ:
|
||||
# Re-invoke pip using the specified Python interpreter
|
||||
interpreter = identify_python_interpreter(general_options.python)
|
||||
if interpreter is None:
|
||||
raise CommandError(
|
||||
f"Could not locate Python interpreter {general_options.python}"
|
||||
)
|
||||
|
||||
pip_cmd = [
|
||||
interpreter,
|
||||
get_runnable_pip(),
|
||||
]
|
||||
pip_cmd.extend(args)
|
||||
|
||||
# Set a flag so the child doesn't re-invoke itself, causing
|
||||
# an infinite loop.
|
||||
os.environ["_PIP_RUNNING_IN_SUBPROCESS"] = "1"
|
||||
returncode = 0
|
||||
try:
|
||||
proc = subprocess.run(pip_cmd)
|
||||
returncode = proc.returncode
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise CommandError(f"Failed to run pip under {interpreter}: {exc}")
|
||||
sys.exit(returncode)
|
||||
|
||||
# --version
|
||||
if general_options.version:
|
||||
sys.stdout.write(parser.version)
|
||||
sys.stdout.write(os.linesep)
|
||||
sys.exit()
|
||||
|
||||
# pip || pip help -> print_help()
|
||||
if not args_else or (args_else[0] == "help" and len(args_else) == 1):
|
||||
parser.print_help()
|
||||
sys.exit()
|
||||
|
||||
# the subcommand name
|
||||
cmd_name = args_else[0]
|
||||
|
||||
if cmd_name not in commands_dict:
|
||||
guess = get_similar_commands(cmd_name)
|
||||
|
||||
msg = [f'unknown command "{cmd_name}"']
|
||||
if guess:
|
||||
msg.append(f'maybe you meant "{guess}"')
|
||||
|
||||
raise CommandError(" - ".join(msg))
|
||||
|
||||
# all the args without the subcommand
|
||||
cmd_args = args[:]
|
||||
cmd_args.remove(cmd_name)
|
||||
|
||||
return cmd_name, cmd_args
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user