13 Commits
dev ... server

858 changed files with 282603 additions and 901 deletions

View File

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

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:label="proyecto_flutter" android:label="proyecto_flutter"
android:name="${applicationName}" android:name="${applicationName}"

View 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
View File

@@ -0,0 +1,242 @@
[
{
"routeId": "RUTA-01",
"name": "Zona Centro - Las Arboledas",
"truckId": 101,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
{ "positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
{ "positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z" },
{ "positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z" },
{ "positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z" },
{ "positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z" },
{ "positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z" }
]
},
{
"routeId": "RUTA-02",
"name": "Sector Norte - Av. Tecnológico",
"truckId": 102,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z" },
{ "positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z" },
{ "positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z" },
{ "positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z" },
{ "positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z" },
{ "positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z" },
{ "positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z" }
]
},
{
"routeId": "RUTA-03",
"name": "Sector Poniente - San Juanico",
"truckId": 103,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
{ "positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z" },
{ "positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z" },
{ "positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
{ "positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
{ "positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z" },
{ "positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z" }
]
},
{
"routeId": "RUTA-04",
"name": "Oriente - Los Olivos",
"truckId": 104,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
{ "positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z" },
{ "positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z" },
{ "positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z" },
{ "positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z" },
{ "positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z" },
{ "positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z" }
]
},
{
"routeId": "RUTA-05",
"name": "Sector Sur - Rancho Seco",
"truckId": 105,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z" },
{ "positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z" },
{ "positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z" },
{ "positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z" },
{ "positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
{ "positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z" },
{ "positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z" }
]
},
{
"routeId": "RUTA-06",
"name": "Norte Extremo - Rumbos de Roque",
"truckId": 106,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
{ "positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z" },
{ "positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z" },
{ "positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z" },
{ "positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
{ "positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z" },
{ "positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z" }
]
},
{
"routeId": "RUTA-07",
"name": "Nororiente - Ciudad Industrial",
"truckId": 107,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
{ "positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z" },
{ "positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z" },
{ "positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z" },
{ "positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z" },
{ "positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
{ "positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z" }
]
},
{
"routeId": "RUTA-08",
"name": "Suroriente - Universidad Latina",
"truckId": 108,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
{ "positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z" },
{ "positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z" },
{ "positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z" },
{ "positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z" },
{ "positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z" },
{ "positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z" }
]
},
{
"routeId": "RUTA-09",
"name": "Poniente - Hospital General",
"truckId": 109,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z" },
{ "positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
{ "positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z" },
{ "positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z" },
{ "positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z" },
{ "positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z" },
{ "positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z" }
]
},
{
"routeId": "RUTA-10",
"name": "Eje Juan Pablo II - Sede UG Sur",
"truckId": 110,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z" },
{ "positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z" },
{ "positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z" },
{ "positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z" },
{ "positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
{ "positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z" },
{ "positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z" }
]
},
{
"routeId": "RUTA-11",
"name": "Zona de Oro - Torres Landa",
"truckId": 111,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z" },
{ "positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z" },
{ "positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z" },
{ "positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z" },
{ "positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z" },
{ "positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z" },
{ "positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z" }
]
},
{
"routeId": "RUTA-12",
"name": "Nororiente - Las Insurgentes",
"truckId": 112,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z" },
{ "positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z" },
{ "positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z" },
{ "positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
{ "positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
{ "positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z" },
{ "positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z" }
]
},
{
"routeId": "RUTA-13",
"name": "Sector Norte - Trojes e Irrigación",
"truckId": 113,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z" },
{ "positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z" },
{ "positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z" },
{ "positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z" },
{ "positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
{ "positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z" },
{ "positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z" }
]
},
{
"routeId": "RUTA-14",
"name": "Sur Poniente - La Toscana",
"truckId": 114,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z" },
{ "positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z" },
{ "positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z" },
{ "positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z" },
{ "positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
{ "positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
{ "positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z" }
]
},
{
"routeId": "RUTA-15",
"name": "Norponiente - Camino a San José de Celaya",
"truckId": 115,
"status": "EN_RUTA",
"positions": [
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z" },
{ "positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z" },
{ "positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z" },
{ "positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z" },
{ "positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z" },
{ "positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z" },
{ "positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z" },
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z" }
]
}
]

View File

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

View 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
View 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
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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),
),
),
),
),
],
),
);
}
}

View 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();
}
}

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

View 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,
),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}

View 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)),
),
),
],
),
);
}
}

View 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
View 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
View 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),
),
),
),
),
],
);
}
}

View 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,
),
);
}
}

View 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),
),
),
],
),
),
),
),
],
);
}
}

View 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
View File

@@ -0,0 +1,4 @@
import 'package:flutter/material.dart';
const Color colorAzul = Color(0xFF0F0D38);
const Color colorAzulClaro = Color(0xFF2A2A5E);

View File

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

View File

@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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);
} }

View File

@@ -3,9 +3,12 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
gtk
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -5,6 +5,14 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import app_links
import geolocator_apple
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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"))
} }

View File

@@ -1,6 +1,46 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: async:
dependency: transitive dependency: transitive
description: description:
@@ -33,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +89,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,6 +121,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +153,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -70,6 +190,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -83,6 +219,203 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -115,6 +448,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -139,6 +496,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@@ -147,6 +536,182 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: riverpod:
dependency: transitive dependency: transitive
description: description:
@@ -155,6 +720,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -184,6 +821,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: stream_channel:
dependency: transitive dependency: transitive
description: description:
@@ -192,6 +837,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" 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: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +853,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -216,6 +885,94 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -232,6 +989,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" 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: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.38.4"

View File

@@ -34,12 +34,25 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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 # activated in the `analysis_options.yaml` file located at the root of your
@@ -58,10 +71,10 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # Assets
# assets: assets:
# - images/a_dot_burr.jpeg - assets/recycling_guide.json
# - images/a_dot_ham.jpeg - assets/rutas.json
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

13
railway.json Normal file
View 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
View 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
View 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
View 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
View 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
View File

0
server/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

View File

@@ -0,0 +1,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

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

View 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")

View 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),
}

View 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

View File

@@ -0,0 +1,3 @@
from app.core.config import settings
__all__ = ["settings"]

198
server/app/core/cache.py Normal file
View 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
View 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()

View 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")

View File

@@ -0,0 +1,3 @@
from app.data.repositories.ruta_repository import SQLiteRutaRepository
__all__ = ["SQLiteRutaRepository"]

View File

View File

@@ -0,0 +1,3 @@
from app.data.repositories.ruta_repository import SQLiteRutaRepository
__all__ = ["SQLiteRutaRepository"]

View 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

View 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
View 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}")

View 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",
]

View 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",
]

View 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

View File

@@ -0,0 +1,3 @@
from app.domain.interfaces.i_ruta_repository import IRutaRepository
__all__ = ["IRutaRepository"]

View 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
View 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}

View 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"]

View 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]

View 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()

View File

@@ -0,0 +1,3 @@
from app.use_cases.obtener_eta import ObtenerETAUseCase, ETAResponse
__all__ = ["ObtenerETAUseCase", "ETAResponse"]

View 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
View 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
View 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
View 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
View 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
View File

View 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

View File

@@ -0,0 +1 @@
__import__('_distutils_hack').do_override()

View File

@@ -0,0 +1 @@
import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enabled and __import__('_distutils_hack').add_shim();

View 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

View File

@@ -0,0 +1 @@
pip

View 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.

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.42.0)
Root-Is-Purelib: true
Tag: py3-none-any

View File

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

View File

@@ -0,0 +1 @@
pip

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

View 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())

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

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

View 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()

View 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")

View File

@@ -0,0 +1,4 @@
"""Subpackage containing all of pip's command line interface related code
"""
# This file intentionally does not import submodules

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

View 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