feat: add api implementation
This commit is contained in:
409
frontend/INTEGRATION.md
Normal file
409
frontend/INTEGRATION.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# Guía Frontend — Integración con el Backend
|
||||
|
||||
App: Expo Router + React Native + TypeScript.
|
||||
Backend: Express en `http://localhost:3000` (ya está listo, no hay que tocarlo).
|
||||
|
||||
> Esta guía está pensada para probar en **emulador de Android**.
|
||||
> Si prueban en otro entorno, sólo cambien la `API_URL` (sección 1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Endpoints del backend
|
||||
|
||||
| Método | Endpoint | Para qué |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/register` | Crear cuenta |
|
||||
| POST | `/api/auth/login` | Iniciar sesión y obtener JWT |
|
||||
| GET | `/api/auth/me` | Datos del usuario (requiere token) |
|
||||
| POST | `/api/tracking/gps-update` | Recibe posición del camión, regresa ETA + notificaciones |
|
||||
|
||||
---
|
||||
|
||||
## 1. Configurar la URL del backend
|
||||
|
||||
Crear `frontend/src/config/api.ts`:
|
||||
|
||||
```ts
|
||||
// IPs según el entorno:
|
||||
// Android Emulator → http://10.0.2.2:3000 (default aquí)
|
||||
// iOS simulator → http://localhost:3000
|
||||
// Dispositivo real → http://<IP-de-tu-laptop>:3000 (ej. 192.168.1.20)
|
||||
// Expo Go web → http://localhost:3000
|
||||
export const API_URL = "http://10.0.2.2:3000";
|
||||
```
|
||||
|
||||
> `10.0.2.2` es la dirección especial del emulador de Android para alcanzar el `localhost` de la laptop. No usen `localhost` desde el emulador, no funciona.
|
||||
|
||||
> Si prueban en un celular físico, sáquen la IP de la laptop con `ipconfig` (Windows) y pongan algo como `http://192.168.1.20:3000`. La laptop y el celular tienen que estar en el **mismo Wi-Fi**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cliente HTTP simple
|
||||
|
||||
Crear `frontend/src/lib/api.ts`:
|
||||
|
||||
```ts
|
||||
import { API_URL } from "../config/api";
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export const setAuthToken = (token: string | null) => {
|
||||
authToken = token;
|
||||
};
|
||||
|
||||
export const apiFetch = async <T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Servicios por feature
|
||||
|
||||
### `frontend/src/services/auth.service.ts`
|
||||
|
||||
```ts
|
||||
import { apiFetch, setAuthToken } from "../lib/api";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const register = (data: { name: string; email: string; password: string }) =>
|
||||
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const login = async (data: { email: string; password: string }) => {
|
||||
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setAuthToken(res.token);
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getMe = () =>
|
||||
apiFetch<AuthUser & { role: string }>("/api/auth/me");
|
||||
```
|
||||
|
||||
### `frontend/src/services/tracking.service.ts`
|
||||
|
||||
```ts
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
export type NotificationType =
|
||||
| "ROUTE_START"
|
||||
| "TRUCK_PROXIMITY"
|
||||
| "ROUTE_COMPLETED"
|
||||
| "DELAY"
|
||||
| "MECHANICAL_FAILURE";
|
||||
|
||||
export interface GpsUpdatePayload {
|
||||
truckId: string;
|
||||
routeId: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
speed: number;
|
||||
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
|
||||
positionId?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface EtaResult {
|
||||
etaMinutes: number;
|
||||
arrivalWindow: { from: string; to: string };
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BackendNotification {
|
||||
userId: number;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GpsUpdateResponse {
|
||||
message: string;
|
||||
truck: { truckId: number; routeId: string; status: string };
|
||||
eta: EtaResult;
|
||||
notifications: BackendNotification[];
|
||||
}
|
||||
|
||||
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
|
||||
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mapear los tipos del backend al componente `AlertItem`
|
||||
|
||||
`AlertItem` ya acepta `started | near | danger | completed`. Crear `frontend/src/lib/notification-mapper.ts`:
|
||||
|
||||
```ts
|
||||
import type { NotificationType } from "../services/tracking.service";
|
||||
|
||||
export const notificationTypeToAlertType = (
|
||||
type: NotificationType,
|
||||
): "started" | "near" | "danger" | "completed" => {
|
||||
switch (type) {
|
||||
case "ROUTE_START": return "started";
|
||||
case "TRUCK_PROXIMITY": return "near";
|
||||
case "ROUTE_COMPLETED": return "completed";
|
||||
case "DELAY": return "danger";
|
||||
case "MECHANICAL_FAILURE": return "danger";
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Pantalla de Login (`src/app/login.tsx`)
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { View, Text, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import InputField from "../components/InputField";
|
||||
import PrimaryButton from "../components/PrimaryButton";
|
||||
import { login } from "../services/auth.service";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState("ana@test.com");
|
||||
const [password, setPassword] = useState("123456");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await login({ email, password });
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Iniciar sesión</Text>
|
||||
<InputField placeholder="Email" value={email} onChangeText={setEmail} />
|
||||
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||
<PrimaryButton title={loading ? "Cargando..." : "Entrar"} onPress={handleLogin} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 24, justifyContent: "center" },
|
||||
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Pantalla principal con ETA en tiempo real (`src/app/index.tsx`)
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, RefreshControl, View } from "react-native";
|
||||
import EtaCard from "../components/EtaCard";
|
||||
import {
|
||||
sendGpsUpdate,
|
||||
type GpsUpdateResponse,
|
||||
type BackendNotification,
|
||||
} from "../services/tracking.service";
|
||||
|
||||
// Para la demo: el frontend simula el avance del camión.
|
||||
// En producción esto vendría de un GPS real → backend → app vía websockets/polling.
|
||||
const SIMULATION_STEPS = [
|
||||
{ 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: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
|
||||
];
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [data, setData] = useState<GpsUpdateResponse | null>(null);
|
||||
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
||||
|
||||
const fetchNext = async () => {
|
||||
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
|
||||
try {
|
||||
const res = await sendGpsUpdate({
|
||||
truckId: "101",
|
||||
routeId: "RUTA-01",
|
||||
status: "EN_RUTA",
|
||||
...step,
|
||||
});
|
||||
setData(res);
|
||||
setNotifications((prev) => [...res.notifications, ...prev]);
|
||||
setStepIndex((i) => i + 1);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchNext(); }, []);
|
||||
|
||||
return (
|
||||
<ScrollView refreshControl={<RefreshControl refreshing={false} onRefresh={fetchNext} />}>
|
||||
<EtaCard
|
||||
minutes={data?.eta.etaMinutes ?? 0}
|
||||
status={data?.eta.message ?? "Cargando..."}
|
||||
/>
|
||||
<View>
|
||||
{/* Mapear notifications a <AlertItem /> aquí si quieren mostrarlas en la home también */}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Truco del demo:** cada "pull to refresh" avanza el camión un paso. Sin tocar el backend, pueden mostrar las 5 fases del recorrido.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pantalla de Alertas (`src/app/alerts.tsx`)
|
||||
|
||||
```tsx
|
||||
import { FlatList, View } from "react-native";
|
||||
import AlertItem from "../components/Alertltem";
|
||||
import { notificationTypeToAlertType } from "../lib/notification-mapper";
|
||||
import type { BackendNotification } from "../services/tracking.service";
|
||||
|
||||
type Props = { notifications: BackendNotification[] };
|
||||
|
||||
export default function AlertsScreen({ notifications }: Props) {
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 16 }}>
|
||||
<FlatList
|
||||
data={notifications}
|
||||
keyExtractor={(_, i) => String(i)}
|
||||
renderItem={({ item }) => (
|
||||
<AlertItem
|
||||
title={item.title}
|
||||
description={item.body}
|
||||
time="ahora"
|
||||
type={notificationTypeToAlertType(item.type)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Compartir notificaciones entre pantallas:** lo más simple para el demo es subirlas a un `Context` o Zustand. Si tienen tiempo. Para hackathon, pueden duplicar la lógica o pasarlas por params.
|
||||
|
||||
---
|
||||
|
||||
## 8. Cómo probar el flujo completo en el emulador de Android
|
||||
|
||||
### Pre-requisitos
|
||||
|
||||
- Backend corriendo: `npm run dev` dentro de `/backend`. Debe imprimir `Server is running on port 3000`.
|
||||
- Android Studio con AVD (emulador) abierto.
|
||||
- Frontend con dependencias instaladas: `npm install` dentro de `/frontend`.
|
||||
|
||||
### Pasos
|
||||
|
||||
1. **Levantar el frontend:**
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm run android
|
||||
```
|
||||
|
||||
Eso abre Expo y lanza la app en el emulador.
|
||||
|
||||
2. **Crear un usuario de prueba** (sólo la primera vez). Desde Postman:
|
||||
|
||||
```
|
||||
POST http://localhost:3000/api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Ana López", "email": "ana@test.com", "password": "123456" }
|
||||
```
|
||||
|
||||
(Ana ya está en el mock con `routeId: "RUTA-01"`, así que recibirá las notificaciones del camión 101.)
|
||||
|
||||
3. **En la app del emulador**, iniciar sesión con:
|
||||
- Email: `ana@test.com`
|
||||
- Password: `123456`
|
||||
|
||||
4. **En la pantalla principal**, hacer **pull-to-refresh** repetidamente. Cada pull avanza el camión:
|
||||
|
||||
| Paso | positionId | Notificación esperada |
|
||||
|---|---|---|
|
||||
| 1 | 1 | (ninguna) |
|
||||
| 2 | 2 | "¡Ruta Iniciada!" |
|
||||
| 3 | 3 | (ninguna) |
|
||||
| 4 | 4 | "Camión Cercano" |
|
||||
| 5 | 8 | "Servicio Finalizado" |
|
||||
|
||||
5. **Pantalla de alertas** debe mostrar las notificaciones acumuladas con los iconos y colores correctos.
|
||||
|
||||
---
|
||||
|
||||
## 9. Problemas comunes en Android emulator
|
||||
|
||||
| Síntoma | Causa | Solución |
|
||||
|---|---|---|
|
||||
| `Network request failed` | Apunta a `localhost` | Usar `http://10.0.2.2:3000` en `API_URL` |
|
||||
| `TypeError: Failed to fetch` (en web) | Falta CORS | Avisar al del backend para que active `cors()` |
|
||||
| 401 al hacer login | Usuario no existe | Registrar primero con Postman (sección 8, paso 2) |
|
||||
| `notifications: []` aunque cambies positionId | El cache ya envió esa noti antes | Reiniciar el backend (`Ctrl+C` y `npm run dev`) |
|
||||
| ETA negativo o raro | El `timestamp` que envías está atrás del target | Es normal, el mensaje sale como "El camión ya pasó..." |
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist mínimo
|
||||
|
||||
- [ ] `src/config/api.ts` con `http://10.0.2.2:3000`
|
||||
- [ ] `src/lib/api.ts` (fetch wrapper)
|
||||
- [ ] `src/services/auth.service.ts`
|
||||
- [ ] `src/services/tracking.service.ts`
|
||||
- [ ] `src/lib/notification-mapper.ts`
|
||||
- [ ] Pantalla de login funcionando con `ana@test.com`
|
||||
- [ ] Pantalla principal mostrando `EtaCard` con datos reales
|
||||
- [ ] Pantalla de alertas mostrando `AlertItem` con notificaciones reales
|
||||
- [ ] Probar las 5 fases del recorrido con pull-to-refresh
|
||||
|
||||
---
|
||||
|
||||
## 11. Lo que NO necesitan hacer
|
||||
|
||||
- ❌ Push notifications reales (Firebase / Expo Notifications) — sólo lista in-app
|
||||
- ❌ Mapas con GPS real — los datos vienen del mock del backend
|
||||
- ❌ AsyncStorage para el token — para el demo basta con estado en memoria
|
||||
- ❌ Modificar nada del `/backend`
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { AppProvider } from "../context/AppContext";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -108,6 +110,12 @@ export default function Layout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="login"
|
||||
options={{ href: null }}
|
||||
/>
|
||||
</Tabs>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { ScrollView, Text } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
import { COLORS } from "../constants/colors";
|
||||
|
||||
import SectionTitle from "../components/SectionTitle";
|
||||
import AlertItem from "@/components/Alertltem";
|
||||
|
||||
import { useApp } from "../context/AppContext";
|
||||
import { notificationTypeToAlertType } from "../lib/notification-mapper";
|
||||
|
||||
export default function AlertsScreen() {
|
||||
const { user, notifications } = useApp();
|
||||
|
||||
if (!user) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
@@ -30,38 +40,22 @@ export default function AlertsScreen() {
|
||||
marginBottom: 24,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
Estado actual de recolección
|
||||
>
|
||||
{notifications.length > 0
|
||||
? `${notifications.length} notificaciones`
|
||||
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
|
||||
</Text>
|
||||
|
||||
<AlertItem
|
||||
title="Ruta iniciada"
|
||||
description="El vehículo de recolección ha comenzado la ruta matutina en tu distrito."
|
||||
time="Justo ahora"
|
||||
type="started"
|
||||
/>
|
||||
|
||||
<AlertItem
|
||||
title="Camión cerca"
|
||||
description="El camión está a 2 cuadras. Asegúrate de sacar tus contenedores."
|
||||
time="Hace 15m"
|
||||
type="near"
|
||||
/>
|
||||
|
||||
<AlertItem
|
||||
title="Retraso detectado"
|
||||
description="Congestión de tráfico en la zona principal puede causar retraso."
|
||||
time="Hace 1h"
|
||||
type="danger"
|
||||
/>
|
||||
|
||||
<AlertItem
|
||||
title="Ruta completada"
|
||||
description="¡Buen trabajo! Tu vecindario logró desviar residuos reciclables."
|
||||
time="Ayer"
|
||||
type="completed"
|
||||
/>
|
||||
{notifications.map((n, i) => (
|
||||
<AlertItem
|
||||
key={`${n.userId}-${n.type}-${i}`}
|
||||
title={n.title}
|
||||
description={n.body}
|
||||
time="ahora"
|
||||
type={notificationTypeToAlertType(n.type)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ScrollView, View, Text } from "react-native";
|
||||
import { useEffect } from "react";
|
||||
import { ScrollView, View, Text, RefreshControl } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Redirect, useRouter } from "expo-router";
|
||||
|
||||
import { COLORS } from "../constants/colors";
|
||||
|
||||
@@ -7,7 +9,27 @@ import SectionTitle from "../components/SectionTitle";
|
||||
import EtaCard from "../components/EtaCard";
|
||||
import QuickAction from "../components/QuickAction";
|
||||
|
||||
import { useApp } from "../context/AppContext";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, eta, loading, advanceTruck } = useApp();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !eta) {
|
||||
advanceTruck();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (!user) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0;
|
||||
const windowText = eta?.arrivalWindow
|
||||
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
|
||||
: "Tira para actualizar";
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
@@ -21,6 +43,9 @@ export default function HomeScreen() {
|
||||
padding: 20,
|
||||
paddingBottom: 120,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={advanceTruck} />
|
||||
}
|
||||
>
|
||||
<SectionTitle title="EcoRuta" />
|
||||
|
||||
@@ -32,10 +57,10 @@ export default function HomeScreen() {
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
Monitoreo inteligente de recolección
|
||||
{eta?.message ?? "Monitoreo inteligente de recolección"}
|
||||
</Text>
|
||||
|
||||
<EtaCard />
|
||||
<EtaCard minutes={minutes} status={windowText} />
|
||||
|
||||
<SectionTitle title="Acciones rápidas" />
|
||||
|
||||
@@ -46,9 +71,17 @@ export default function HomeScreen() {
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<QuickAction title="Alertas" icon="notifications-outline" />
|
||||
<QuickAction
|
||||
title="Alertas"
|
||||
icon="notifications-outline"
|
||||
onPress={() => router.push("/alerts")}
|
||||
/>
|
||||
|
||||
<QuickAction title="Reportar" icon="warning-outline" />
|
||||
<QuickAction
|
||||
title="Actualizar"
|
||||
icon="refresh-outline"
|
||||
onPress={advanceTruck}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
40
frontend/src/app/login.tsx
Normal file
40
frontend/src/app/login.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import InputField from "../components/InputField";
|
||||
import PrimaryButton from "../components/PrimaryButton";
|
||||
import { useApp } from "../context/AppContext";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState("ana@test.com");
|
||||
const [password, setPassword] = useState("123456");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { login } = useApp();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await login(email, password);
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Iniciar sesión</Text>
|
||||
<InputField placeholder="Email" value={email} onChangeText={setEmail} />
|
||||
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||
<PrimaryButton title={submitting ? "Cargando..." : "Entrar"} onPress={handleLogin} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 24, justifyContent: "center" },
|
||||
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20, textAlign: "center" },
|
||||
});
|
||||
@@ -1,15 +1,67 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
import { COLORS } from "../constants/colors";
|
||||
import PrimaryButton from "../components/PrimaryButton";
|
||||
|
||||
import { useApp } from "../context/AppContext";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, logout } = useApp();
|
||||
const router = useRouter();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>No has iniciado sesión</Text>
|
||||
<PrimaryButton
|
||||
title="Iniciar sesión"
|
||||
onPress={() => router.push("/login")}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Perfil</Text>
|
||||
</View>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Hola, {user.name}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
<View style={{ height: 24 }} />
|
||||
<PrimaryButton title="Cerrar sesión" onPress={handleLogout} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
email: {
|
||||
fontSize: 14,
|
||||
color: "#6B7280",
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
|
||||
1
frontend/src/config/api.ts
Normal file
1
frontend/src/config/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_URL = "http://10.0.2.2:8080";
|
||||
102
frontend/src/context/AppContext.tsx
Normal file
102
frontend/src/context/AppContext.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { setAuthToken } from "../lib/api";
|
||||
import { login as loginService, type AuthUser } from "../services/auth.service";
|
||||
import {
|
||||
sendGpsUpdate,
|
||||
type BackendNotification,
|
||||
type EtaResult,
|
||||
} from "../services/tracking.service";
|
||||
|
||||
const SIMULATION_STEPS = [
|
||||
{ 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: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
|
||||
];
|
||||
|
||||
interface AppContextValue {
|
||||
user: AuthUser | null;
|
||||
eta: EtaResult | null;
|
||||
notifications: BackendNotification[];
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
advanceTruck: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
export const useApp = (): AppContextValue => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error("useApp must be used within AppProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [eta, setEta] = useState<EtaResult | null>(null);
|
||||
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await loginService({ email, password });
|
||||
setUser(res.user);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setAuthToken(null);
|
||||
setUser(null);
|
||||
setEta(null);
|
||||
setNotifications([]);
|
||||
setStepIndex(0);
|
||||
}, []);
|
||||
|
||||
const advanceTruck = useCallback(async () => {
|
||||
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await sendGpsUpdate({
|
||||
truckId: "101",
|
||||
routeId: "RUTA-01",
|
||||
status: "EN_RUTA",
|
||||
...step,
|
||||
});
|
||||
setEta(res.eta);
|
||||
|
||||
// Sólo mostramos las notificaciones que pertenecen al usuario logueado.
|
||||
const myNotifications = user
|
||||
? res.notifications.filter((n) => n.userId === user.id)
|
||||
: [];
|
||||
|
||||
if (myNotifications.length > 0) {
|
||||
setNotifications((prev) => [...myNotifications, ...prev]);
|
||||
}
|
||||
setStepIndex((i) => i + 1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stepIndex, user]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{ user, eta, notifications, loading, login, logout, advanceTruck }}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
30
frontend/src/lib/api.ts
Normal file
30
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API_URL } from "../config/api";
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export const setAuthToken = (token: string | null) => {
|
||||
authToken = token;
|
||||
};
|
||||
|
||||
export const apiFetch = async <T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
};
|
||||
14
frontend/src/lib/notification-mapper.ts
Normal file
14
frontend/src/lib/notification-mapper.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NotificationType } from "../services/tracking.service";
|
||||
|
||||
export const notificationTypeToAlertType = (
|
||||
type: NotificationType,
|
||||
): "started" | "near" | "danger" | "completed" => {
|
||||
switch (type) {
|
||||
case "ROUTE_START": return "started";
|
||||
case "TRUCK_PROXIMITY": return "near";
|
||||
case "TRUCK_ARRIVED": return "near";
|
||||
case "ROUTE_COMPLETED": return "completed";
|
||||
case "DELAY": return "danger";
|
||||
case "MECHANICAL_FAILURE": return "danger";
|
||||
}
|
||||
};
|
||||
25
frontend/src/services/auth.service.ts
Normal file
25
frontend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { apiFetch, setAuthToken } from "../lib/api";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const register = (data: { name: string; email: string; password: string }) =>
|
||||
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const login = async (data: { email: string; password: string }) => {
|
||||
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setAuthToken(res.token);
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getMe = () =>
|
||||
apiFetch<AuthUser & { role: string }>("/api/auth/me");
|
||||
46
frontend/src/services/tracking.service.ts
Normal file
46
frontend/src/services/tracking.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
export type NotificationType =
|
||||
| "ROUTE_START"
|
||||
| "TRUCK_PROXIMITY"
|
||||
| "TRUCK_ARRIVED"
|
||||
| "ROUTE_COMPLETED"
|
||||
| "DELAY"
|
||||
| "MECHANICAL_FAILURE";
|
||||
|
||||
export interface GpsUpdatePayload {
|
||||
truckId: string;
|
||||
routeId: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
speed: number;
|
||||
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
|
||||
positionId?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface EtaResult {
|
||||
etaMinutes: number;
|
||||
arrivalWindow: { from: string; to: string };
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BackendNotification {
|
||||
userId: number;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GpsUpdateResponse {
|
||||
message: string;
|
||||
truck: { truckId: number; routeId: string; status: string };
|
||||
eta: EtaResult;
|
||||
notifications: BackendNotification[];
|
||||
}
|
||||
|
||||
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
|
||||
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
Reference in New Issue
Block a user