feat(frontend): register, guide, feedback, addresses & status polling

- Add register screen with onboarding redirect to address validation
- Add waste separation guide screen with 4 categories and offline tips
  (organicos, reciclables, sanitarios, especiales) plus preventive
  messaging banner
- Add feedback submission screen with 4 types and 1-5 star rating
- Add address screen: list colonias, pick one, validate against backend
- Switch from pull-to-refresh GPS hack to periodic polling of
  /tracking/status (30s) — backend now drives the simulation
- Filter notifications by logged-in user.id (tunnel-view on client side)
- Add register/logout/address actions to profile screen
- Hide login/register/feedback/addresses from tab bar (href: null)
- Set API_URL to LAN IP for physical phone testing over hotspot
This commit is contained in:
Diego Mireles
2026-05-23 02:34:13 -06:00
parent 59fcad643a
commit 131eeacbd2
14 changed files with 1034 additions and 81 deletions

View File

@@ -115,6 +115,21 @@ export default function Layout() {
name="login" name="login"
options={{ href: null }} options={{ href: null }}
/> />
<Tabs.Screen
name="register"
options={{ href: null }}
/>
<Tabs.Screen
name="feedback"
options={{ href: null }}
/>
<Tabs.Screen
name="addresses"
options={{ href: null }}
/>
</Tabs> </Tabs>
</AppProvider> </AppProvider>
); );

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from "react";
import {
ScrollView,
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
import {
listColonias,
getMyAddress,
setMyAddress,
type Colonia,
type MyAddress,
} from "../services/addresses.service";
export default function AddressesScreen() {
const { user, refreshStatus } = useApp();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [colonias, setColonias] = useState<Colonia[]>([]);
const [myAddress, setMy] = useState<MyAddress | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
void (async () => {
try {
const [c, mine] = await Promise.all([listColonias(), getMyAddress()]);
setColonias(c);
setMy(mine);
setSelected(mine.colonia);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
})();
}, [user]);
if (!user) return <Redirect href="/login" />;
const handleSave = async () => {
if (!selected) {
Alert.alert("Selecciona una colonia.");
return;
}
try {
setSaving(true);
const result = await setMyAddress(selected);
setMy(result);
await refreshStatus();
Alert.alert(
"Domicilio validado",
`Tu zona es ${result.colonia}. Horario: ${result.horarioEstimado}`,
);
router.replace("/");
} catch (err) {
Alert.alert(
"No se pudo guardar",
err instanceof Error ? err.message : "Error desconocido",
);
} finally {
setSaving(false);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
>
<SectionTitle title="Mi domicilio" />
<Text style={styles.subtitle}>
Selecciona la colonia donde quieres recibir avisos de recolección.
Solo verás la ruta que cubre tu zona.
</Text>
{myAddress && (
<View style={styles.currentBox}>
<Text style={styles.currentLabel}>Domicilio actual</Text>
<Text style={styles.currentValue}>{myAddress.colonia}</Text>
<Text style={styles.currentMeta}>
Ruta {myAddress.routeId}
{myAddress.horarioEstimado
? `${myAddress.horarioEstimado}`
: ""}
</Text>
</View>
)}
<Text style={styles.label}>Colonias disponibles</Text>
{loading ? (
<ActivityIndicator color="#0E8A61" style={{ marginTop: 20 }} />
) : (
colonias.map((c) => {
const active = selected === c.colonia;
return (
<Pressable
key={c.colonia}
onPress={() => setSelected(c.colonia)}
style={[
styles.optionRow,
active && {
borderColor: "#0E8A61",
borderWidth: 2,
},
]}
>
<Ionicons
name={active ? "radio-button-on" : "radio-button-off"}
size={22}
color={active ? "#0E8A61" : "#9CA3AF"}
/>
<View style={{ marginLeft: 12, flex: 1 }}>
<Text style={styles.optionTitle}>{c.colonia}</Text>
<Text style={styles.optionMeta}>
Ruta {c.routeId} {c.horarioEstimado}
</Text>
</View>
</Pressable>
);
})
)}
<View style={{ height: 16 }} />
<PrimaryButton
title={saving ? "Guardando..." : "Validar domicilio"}
onPress={handleSave}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
subtitle: {
color: "#6B7280",
fontSize: 14,
marginBottom: 16,
marginLeft: 12,
},
currentBox: {
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 16,
marginBottom: 18,
marginHorizontal: 2,
borderLeftWidth: 4,
borderLeftColor: "#0E8A61",
},
currentLabel: {
fontSize: 11,
color: "#6B7280",
fontWeight: "700",
marginBottom: 4,
textTransform: "uppercase",
},
currentValue: { fontSize: 18, fontWeight: "800", color: "#1F2937" },
currentMeta: { fontSize: 13, color: "#6B7280", marginTop: 4 },
label: {
fontSize: 13,
fontWeight: "700",
color: "#374151",
marginBottom: 10,
marginLeft: 12,
},
optionRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 12,
padding: 14,
marginBottom: 10,
borderColor: "transparent",
borderWidth: 2,
},
optionTitle: { fontSize: 15, fontWeight: "700", color: "#1F2937" },
optionMeta: { fontSize: 12, color: "#6B7280", marginTop: 2 },
});

View File

@@ -46,12 +46,15 @@ export default function AlertsScreen() {
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."} : "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
</Text> </Text>
{notifications.map((n, i) => ( {notifications.map((n) => (
<AlertItem <AlertItem
key={`${n.userId}-${n.type}-${i}`} key={n.id}
title={n.title} title={n.title}
description={n.body} description={n.body}
time="ahora" time={new Date(n.createdAt).toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
})}
type={notificationTypeToAlertType(n.type)} type={notificationTypeToAlertType(n.type)}
/> />
))} ))}

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import {
ScrollView,
View,
Text,
TextInput,
StyleSheet,
Pressable,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
import {
submitFeedback,
type FeedbackType,
} from "../services/feedback.service";
type Option = {
type: FeedbackType;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
};
const OPTIONS: Option[] = [
{
type: "TRUCK_DID_NOT_PASS",
label: "El camión no pasó",
icon: "alert-circle-outline",
color: "#EF4444",
},
{
type: "RATING",
label: "Calificar el servicio",
icon: "star-outline",
color: "#F59E0B",
},
{
type: "SUGGESTION",
label: "Sugerencia",
icon: "bulb-outline",
color: "#3B82F6",
},
{
type: "OTHER",
label: "Otro",
icon: "chatbubble-outline",
color: "#6B7280",
},
];
export default function FeedbackScreen() {
const { user } = useApp();
const router = useRouter();
const [selectedType, setSelectedType] = useState<FeedbackType>(
"TRUCK_DID_NOT_PASS",
);
const [message, setMessage] = useState("");
const [rating, setRating] = useState<number | undefined>(undefined);
const [submitting, setSubmitting] = useState(false);
if (!user) return <Redirect href="/login" />;
const handleSubmit = async () => {
if (!message.trim()) {
Alert.alert("Mensaje vacío", "Cuéntanos qué pasó.");
return;
}
try {
setSubmitting(true);
const payload: { type: FeedbackType; message: string; rating?: number } =
{ type: selectedType, message };
if (selectedType === "RATING" && rating) payload.rating = rating;
await submitFeedback(payload);
Alert.alert("Gracias", "Tu retroalimentación fue enviada.");
setMessage("");
setRating(undefined);
router.back();
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "No se pudo enviar.");
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
>
<SectionTitle title="Buzón de retroalimentación" />
<Text style={styles.subtitle}>
Reporta incidencias o califica el servicio. Tu mensaje llega
directamente al equipo operativo.
</Text>
<Text style={styles.label}>Tipo de mensaje</Text>
<View style={styles.optionsGrid}>
{OPTIONS.map((opt) => {
const active = selectedType === opt.type;
return (
<Pressable
key={opt.type}
onPress={() => setSelectedType(opt.type)}
style={[
styles.optionCard,
active && { borderColor: opt.color, borderWidth: 2 },
]}
>
<Ionicons name={opt.icon} size={24} color={opt.color} />
<Text style={styles.optionLabel}>{opt.label}</Text>
</Pressable>
);
})}
</View>
{selectedType === "RATING" && (
<>
<Text style={styles.label}>Calificación</Text>
<View style={styles.starsRow}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} onPress={() => setRating(n)}>
<Ionicons
name={rating && n <= rating ? "star" : "star-outline"}
size={34}
color="#F59E0B"
style={{ marginRight: 8 }}
/>
</Pressable>
))}
</View>
</>
)}
<Text style={styles.label}>Mensaje</Text>
<TextInput
style={styles.textArea}
placeholder="Cuéntanos qué pasó..."
placeholderTextColor="#9CA3AF"
value={message}
onChangeText={setMessage}
multiline
numberOfLines={5}
/>
<View style={{ height: 16 }} />
<PrimaryButton
title={submitting ? "Enviando..." : "Enviar"}
onPress={handleSubmit}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
subtitle: {
color: "#6B7280",
fontSize: 14,
marginBottom: 20,
marginLeft: 12,
},
label: {
fontSize: 13,
fontWeight: "700",
color: "#374151",
marginTop: 8,
marginBottom: 10,
marginLeft: 12,
},
optionsGrid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
},
optionCard: {
width: "48%",
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
marginBottom: 12,
borderColor: "transparent",
borderWidth: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 2,
},
optionLabel: {
fontSize: 13,
fontWeight: "600",
color: "#1F2937",
marginTop: 8,
},
starsRow: {
flexDirection: "row",
marginLeft: 12,
marginBottom: 10,
},
textArea: {
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
minHeight: 110,
fontSize: 14,
color: "#1F2937",
textAlignVertical: "top",
marginHorizontal: 2,
},
});

View File

@@ -1,15 +1,179 @@
import { View, Text } from "react-native"; import { ScrollView, View, Text, StyleSheet } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
type Category = {
key: string;
title: string;
color: string;
icon: keyof typeof Ionicons.glyphMap;
examples: string[];
tip: string;
};
const CATEGORIES: Category[] = [
{
key: "organico",
title: "Orgánicos",
color: "#22C55E",
icon: "leaf",
examples: [
"Restos de comida (frutas, verduras, cáscaras)",
"Bolsitas de té, residuos de café",
"Hojas, ramas y poda de jardín",
],
tip: "Escurre líquidos antes de tirar. Idealmente compostable.",
},
{
key: "reciclable",
title: "Reciclables",
color: "#3B82F6",
icon: "refresh-circle",
examples: [
"Botellas y envases PET limpios",
"Cartón y papel seco",
"Latas de aluminio, vidrio sin romper",
],
tip: "Limpia y aplasta los envases para ocupar menos espacio.",
},
{
key: "sanitario",
title: "Sanitarios",
color: "#9CA3AF",
icon: "medkit",
examples: [
"Pañales y toallas femeninas",
"Papel higiénico usado",
"Curaciones y cubrebocas",
],
tip: "Sepáralos siempre. NO van con orgánicos ni reciclables.",
},
{
key: "especial",
title: "Especiales",
color: "#EF4444",
icon: "warning",
examples: [
"Pilas y baterías",
"Electrónicos viejos",
"Aceite de cocina usado",
"Medicamentos caducados",
],
tip: "NO los tires con la basura común. Llévalos a un centro de acopio.",
},
];
export default function GuideScreen() { export default function GuideScreen() {
return ( return (
<View <SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
style={{ <ScrollView
flex: 1, showsVerticalScrollIndicator={false}
justifyContent: "center", contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
alignItems: "center", >
}} <SectionTitle title="Guía de separación" />
<Text
style={{
color: "#6B7280",
fontSize: 14,
marginBottom: 20,
marginLeft: 12,
}}
>
Separar correctamente reduce contaminación y ayuda a la ruta de
recolección. Funciona sin conexión.
</Text>
{CATEGORIES.map((cat) => (
<View key={cat.key} style={styles.card}>
<View
style={[
styles.iconWrap,
{ backgroundColor: `${cat.color}20` },
]}
> >
<Text>Guía</Text> <Ionicons name={cat.icon} size={28} color={cat.color} />
</View>
<View style={styles.cardContent}>
<Text style={[styles.cardTitle, { color: cat.color }]}>
{cat.title}
</Text>
{cat.examples.map((ex, i) => (
<Text key={i} style={styles.example}>
{ex}
</Text>
))}
<View
style={[
styles.tipBox,
{ backgroundColor: `${cat.color}15` },
]}
>
<Text style={[styles.tipText, { color: cat.color }]}>
💡 {cat.tip}
</Text>
</View>
</View>
</View>
))}
<View style={styles.preventiveBox}>
<Text style={styles.preventiveTitle}>Recuerda</Text>
<Text style={styles.preventiveBody}>
Saca tu basura sólo dentro del horario de recolección.{"\n"}
No persigas al camión, es peligroso.{"\n"}
Si tu camión no pasó, usa el buzón de retroalimentación.
</Text>
</View> </View>
); </ScrollView>
</SafeAreaView>
);
} }
const styles = StyleSheet.create({
card: {
flexDirection: "row",
backgroundColor: "#FFFFFF",
borderRadius: 18,
padding: 16,
marginBottom: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 3,
},
iconWrap: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: "center",
alignItems: "center",
marginRight: 14,
},
cardContent: { flex: 1 },
cardTitle: { fontSize: 17, fontWeight: "800", marginBottom: 6 },
example: { fontSize: 13, color: "#4B5563", marginBottom: 3 },
tipBox: {
marginTop: 10,
padding: 10,
borderRadius: 10,
},
tipText: { fontSize: 12, fontWeight: "600" },
preventiveBox: {
marginTop: 8,
padding: 16,
backgroundColor: "#FEF3C7",
borderRadius: 14,
},
preventiveTitle: {
fontSize: 14,
fontWeight: "800",
color: "#92400E",
marginBottom: 6,
},
preventiveBody: { fontSize: 13, color: "#92400E", lineHeight: 20 },
});

View File

@@ -1,4 +1,3 @@
import { useEffect } from "react";
import { ScrollView, View, Text, RefreshControl } from "react-native"; import { ScrollView, View, Text, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { Redirect, useRouter } from "expo-router"; import { Redirect, useRouter } from "expo-router";
@@ -12,15 +11,9 @@ import QuickAction from "../components/QuickAction";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
export default function HomeScreen() { export default function HomeScreen() {
const { user, eta, loading, advanceTruck } = useApp(); const { user, eta, route, loading, refreshStatus } = useApp();
const router = useRouter(); const router = useRouter();
useEffect(() => {
if (user && !eta) {
advanceTruck();
}
}, [user]);
if (!user) { if (!user) {
return <Redirect href="/login" />; return <Redirect href="/login" />;
} }
@@ -28,7 +21,10 @@ export default function HomeScreen() {
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0; const minutes = eta ? Math.max(0, eta.etaMinutes) : 0;
const windowText = eta?.arrivalWindow const windowText = eta?.arrivalWindow
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}` ? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
: "Tira para actualizar"; : "Esperando datos del camión...";
const subtitle =
eta?.message ?? `Monitoreando ruta ${route?.routeId ?? "..."}`;
return ( return (
<SafeAreaView <SafeAreaView
@@ -44,7 +40,7 @@ export default function HomeScreen() {
paddingBottom: 120, paddingBottom: 120,
}} }}
refreshControl={ refreshControl={
<RefreshControl refreshing={loading} onRefresh={advanceTruck} /> <RefreshControl refreshing={loading} onRefresh={refreshStatus} />
} }
> >
<SectionTitle title="EcoRuta" /> <SectionTitle title="EcoRuta" />
@@ -57,11 +53,53 @@ export default function HomeScreen() {
marginLeft: 12, marginLeft: 12,
}} }}
> >
{eta?.message ?? "Monitoreo inteligente de recolección"} {subtitle}
</Text> </Text>
<EtaCard minutes={minutes} status={windowText} /> <EtaCard minutes={minutes} status={windowText} />
{route?.horarioEstimado && (
<View
style={{
backgroundColor: "#ECFDF5",
padding: 14,
borderRadius: 14,
marginHorizontal: 15,
marginTop: 4,
borderLeftWidth: 4,
borderLeftColor: "#0E8A61",
}}
>
<Text style={{ color: "#065F46", fontSize: 11, fontWeight: "700" }}>
PRÓXIMA RECOLECCIÓN
</Text>
<Text
style={{
color: "#065F46",
fontSize: 14,
fontWeight: "600",
marginTop: 4,
}}
>
{route.horarioEstimado}
</Text>
</View>
)}
<View
style={{
backgroundColor: "#FEF3C7",
padding: 12,
borderRadius: 12,
marginHorizontal: 15,
marginTop: 10,
}}
>
<Text style={{ color: "#92400E", fontSize: 12, fontWeight: "600" }}>
No saques tus residuos fuera del horario y no persigas al camión.
</Text>
</View>
<SectionTitle title="Acciones rápidas" /> <SectionTitle title="Acciones rápidas" />
<View <View
@@ -78,9 +116,9 @@ export default function HomeScreen() {
/> />
<QuickAction <QuickAction
title="Actualizar" title="Reportar"
icon="refresh-outline" icon="chatbubble-outline"
onPress={advanceTruck} onPress={() => router.push("/feedback")}
/> />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,8 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { View, Text, StyleSheet, Alert } from "react-native"; import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import InputField from "../components/InputField"; import InputField from "../components/InputField";
import PrimaryButton from "../components/PrimaryButton"; import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
export default function LoginScreen() { export default function LoginScreen() {
@@ -25,16 +29,61 @@ export default function LoginScreen() {
}; };
return ( return (
<View style={styles.container}> <SafeAreaView style={styles.container}>
<Text style={styles.title}>Iniciar sesión</Text> <View style={styles.content}>
<InputField placeholder="Email" value={email} onChangeText={setEmail} /> <Text style={styles.title}>Iniciar sesión</Text>
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry /> <Text style={styles.subtitle}>
<PrimaryButton title={submitting ? "Cargando..." : "Entrar"} onPress={handleLogin} /> Accede para ver el estado del camión en tu zona.
</View> </Text>
<InputField
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<InputField
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<View style={{ height: 8 }} />
<PrimaryButton
title={submitting ? "Cargando..." : "Entrar"}
onPress={handleLogin}
/>
<Pressable
onPress={() => router.replace("/register")}
style={styles.linkWrap}
>
<Text style={styles.link}>
¿No tienes cuenta?{" "}
<Text style={styles.linkBold}>Regístrate</Text>
</Text>
</Pressable>
</View>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: "center" }, container: { flex: 1, backgroundColor: COLORS.background },
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20, textAlign: "center" }, content: { flex: 1, padding: 24, justifyContent: "center" },
title: {
fontSize: 26,
fontWeight: "bold",
textAlign: "center",
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: "#6B7280",
textAlign: "center",
marginBottom: 22,
},
linkWrap: { marginTop: 18, alignItems: "center" },
link: { fontSize: 14, color: "#6B7280" },
linkBold: { color: "#0E8A61", fontWeight: "700" },
}); });

View File

@@ -1,4 +1,4 @@
import { View, Text, StyleSheet } from "react-native"; import { View, Text, StyleSheet, Alert } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -6,11 +6,25 @@ import { COLORS } from "../constants/colors";
import PrimaryButton from "../components/PrimaryButton"; import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
import { resetDemo } from "../services/tracking.service";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useApp(); const { user, logout, refreshStatus } = useApp();
const router = useRouter(); const router = useRouter();
const handleResetDemo = async () => {
try {
await resetDemo();
await refreshStatus();
Alert.alert("Demo reiniciado", "Estado y notificaciones borradas.");
} catch (err) {
Alert.alert(
"Error",
err instanceof Error ? err.message : "No se pudo reiniciar",
);
}
};
if (!user) { if (!user) {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
@@ -35,7 +49,30 @@ export default function ProfileScreen() {
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.title}>Hola, {user.name}</Text> <Text style={styles.title}>Hola, {user.name}</Text>
<Text style={styles.email}>{user.email}</Text> <Text style={styles.email}>{user.email}</Text>
<View style={{ height: 24 }} /> <View style={{ height: 24 }} />
<PrimaryButton
title="Mi domicilio"
onPress={() => router.push("/addresses")}
/>
<View style={{ height: 12 }} />
<PrimaryButton
title="Buzón de retroalimentación"
onPress={() => router.push("/feedback")}
/>
<View style={{ height: 12 }} />
<PrimaryButton
title="Reiniciar demo"
onPress={handleResetDemo}
/>
<View style={{ height: 12 }} />
<PrimaryButton title="Cerrar sesión" onPress={handleLogout} /> <PrimaryButton title="Cerrar sesión" onPress={handleLogout} />
</View> </View>
</SafeAreaView> </SafeAreaView>

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import InputField from "../components/InputField";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
export default function RegisterScreen() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const { register } = useApp();
const router = useRouter();
const handleRegister = async () => {
if (!name.trim() || !email.trim() || !password.trim()) {
Alert.alert("Datos faltantes", "Completa todos los campos.");
return;
}
if (password.length < 6) {
Alert.alert("Password corto", "Debe tener al menos 6 caracteres.");
return;
}
try {
setSubmitting(true);
await register(name, email, password);
// Onboarding: tras registrar, pedir que valide su domicilio.
router.replace("/addresses");
} catch (err) {
Alert.alert(
"No se pudo crear la cuenta",
err instanceof Error ? err.message : "Error desconocido",
);
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Crear cuenta</Text>
<Text style={styles.subtitle}>
Registra tu correo para recibir alertas de recolección.
</Text>
<InputField placeholder="Nombre" value={name} onChangeText={setName} />
<InputField
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<InputField
placeholder="Password (mínimo 6 caracteres)"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<View style={{ height: 8 }} />
<PrimaryButton
title={submitting ? "Creando..." : "Registrarme"}
onPress={handleRegister}
/>
<Pressable
onPress={() => router.replace("/login")}
style={styles.linkWrap}
>
<Text style={styles.link}>
¿Ya tienes cuenta? <Text style={styles.linkBold}>Inicia sesión</Text>
</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.background },
content: { flex: 1, padding: 24, justifyContent: "center" },
title: {
fontSize: 26,
fontWeight: "bold",
textAlign: "center",
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: "#6B7280",
textAlign: "center",
marginBottom: 22,
},
linkWrap: { marginTop: 18, alignItems: "center" },
link: { fontSize: 14, color: "#6B7280" },
linkBold: { color: "#0E8A61", fontWeight: "700" },
});

View File

@@ -1 +1,3 @@
export const API_URL = "http://10.0.2.2:8080"; // android
// export const API_URL = "http://10.0.2.2:8080";
export const API_URL = "http://192.168.93.148:8080";

View File

@@ -2,35 +2,37 @@ import React, {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { setAuthToken } from "../lib/api"; import { setAuthToken } from "../lib/api";
import { login as loginService, type AuthUser } from "../services/auth.service";
import { import {
sendGpsUpdate, login as loginService,
type BackendNotification, register as registerService,
type AuthUser,
} from "../services/auth.service";
import {
getMyStatus,
type EtaResult, type EtaResult,
type InboxNotification,
type UserStatusResponse,
} from "../services/tracking.service"; } from "../services/tracking.service";
const SIMULATION_STEPS = [ const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
{ 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 { interface AppContextValue {
user: AuthUser | null; user: AuthUser | null;
eta: EtaResult | null; eta: EtaResult | null;
notifications: BackendNotification[]; notifications: InboxNotification[];
route: UserStatusResponse["route"] | null;
loading: boolean; loading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
advanceTruck: () => Promise<void>; refreshStatus: () => Promise<void>;
} }
const AppContext = createContext<AppContextValue | null>(null); const AppContext = createContext<AppContextValue | null>(null);
@@ -44,10 +46,26 @@ export const useApp = (): AppContextValue => {
export const AppProvider = ({ children }: { children: ReactNode }) => { export const AppProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [eta, setEta] = useState<EtaResult | null>(null); const [eta, setEta] = useState<EtaResult | null>(null);
const [notifications, setNotifications] = useState<BackendNotification[]>([]); const [notifications, setNotifications] = useState<InboxNotification[]>([]);
const [stepIndex, setStepIndex] = useState(0); const [route, setRoute] = useState<UserStatusResponse["route"] | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshStatus = useCallback(async () => {
setLoading(true);
try {
const res = await getMyStatus();
setEta(res.eta);
setRoute(res.route);
setNotifications(res.notifications);
} catch (err) {
console.error("Status refresh failed:", err);
} finally {
setLoading(false);
}
}, []);
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
setLoading(true); setLoading(true);
try { try {
@@ -58,43 +76,66 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
} }
}, []); }, []);
const register = useCallback(
async (name: string, email: string, password: string) => {
setLoading(true);
try {
// 1) crear cuenta
await registerService({ name, email, password });
// 2) hacer login inmediato para guardar el token y el user
const res = await loginService({ email, password });
setUser(res.user);
} finally {
setLoading(false);
}
},
[],
);
const logout = useCallback(() => { const logout = useCallback(() => {
setAuthToken(null); setAuthToken(null);
setUser(null); setUser(null);
setEta(null); setEta(null);
setRoute(null);
setNotifications([]); setNotifications([]);
setStepIndex(0);
}, []); }, []);
const advanceTruck = useCallback(async () => { // Polling controlado: arranca al loguearse, se detiene al deslogearse.
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length]; useEffect(() => {
setLoading(true); if (!user) {
try { if (pollingRef.current) {
const res = await sendGpsUpdate({ clearInterval(pollingRef.current);
truckId: "101", pollingRef.current = null;
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); return;
} finally {
setLoading(false);
} }
}, [stepIndex, user]);
void refreshStatus();
pollingRef.current = setInterval(() => {
void refreshStatus();
}, POLL_INTERVAL_MS);
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [user, refreshStatus]);
return ( return (
<AppContext.Provider <AppContext.Provider
value={{ user, eta, notifications, loading, login, logout, advanceTruck }} value={{
user,
eta,
notifications,
route,
loading,
login,
register,
logout,
refreshStatus,
}}
> >
{children} {children}
</AppContext.Provider> </AppContext.Provider>

View File

@@ -0,0 +1,25 @@
import { apiFetch } from "../lib/api";
export interface Colonia {
colonia: string;
routeId: string;
horarioEstimado: string;
}
export interface MyAddress {
colonia: string;
routeId: string;
horarioEstimado: string | null;
}
export const listColonias = () =>
apiFetch<Colonia[]>("/api/addresses/colonias");
export const getMyAddress = () =>
apiFetch<MyAddress>("/api/addresses/me");
export const setMyAddress = (colonia: string, street?: string) =>
apiFetch<MyAddress>("/api/addresses/me", {
method: "PUT",
body: JSON.stringify({ colonia, street }),
});

View File

@@ -0,0 +1,31 @@
import { apiFetch } from "../lib/api";
export type FeedbackType =
| "TRUCK_DID_NOT_PASS"
| "RATING"
| "SUGGESTION"
| "OTHER";
export interface FeedbackPayload {
type: FeedbackType;
message: string;
rating?: number;
}
export interface FeedbackItem {
id: string;
userId: number;
type: FeedbackType;
message: string;
rating?: number;
createdAt: string;
}
export const submitFeedback = (payload: FeedbackPayload) =>
apiFetch<FeedbackItem>("/api/feedback", {
method: "POST",
body: JSON.stringify(payload),
});
export const listMyFeedback = () =>
apiFetch<FeedbackItem[]>("/api/feedback/me");

View File

@@ -44,3 +44,31 @@ export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
export interface InboxNotification {
id: string;
userId: number;
type: NotificationType;
title: string;
body: string;
createdAt: string;
}
export interface UserStatusResponse {
user: { id: number; name: string; colonia: string };
route: {
routeId: string;
currentPositionId: number;
status: string;
updatedAt: string;
horarioEstimado: string | null;
};
eta: EtaResult | null;
notifications: InboxNotification[];
}
export const getMyStatus = () =>
apiFetch<UserStatusResponse>("/api/tracking/status");
export const resetDemo = () =>
apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST" });