feat: add UI screens design

This commit is contained in:
Diego Mireles
2026-05-23 05:00:54 -06:00
parent 131eeacbd2
commit d280b3865e
18 changed files with 1364 additions and 282 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

View File

@@ -1,64 +1,392 @@
import { ScrollView, Text } from "react-native"; import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { COLORS } from "../constants/colors"; import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import AlertItem from "@/components/Alertltem";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
import { notificationTypeToAlertType } from "../lib/notification-mapper"; import type { NotificationType } from "../services/tracking.service";
type Severity = "info" | "attention" | "important" | "critical";
const meta: Record<
NotificationType,
{
severity: Severity;
icon: keyof typeof Ionicons.glyphMap;
color: string;
bg: string;
badge: string;
}
> = {
ROUTE_START: {
severity: "info",
icon: "navigate",
color: "#3B82F6",
bg: "#EFF6FF",
badge: "Informativo",
},
TRUCK_PROXIMITY: {
severity: "attention",
icon: "time-outline",
color: "#F59E0B",
bg: "#FEF3C7",
badge: "Atención",
},
TRUCK_ARRIVED: {
severity: "important",
icon: "notifications",
color: "#22C55E",
bg: "#ECFDF5",
badge: "Importante",
},
ROUTE_COMPLETED: {
severity: "info",
icon: "checkmark-done",
color: "#6B7280",
bg: "#F3F4F6",
badge: "Informativo",
},
DELAY: {
severity: "critical",
icon: "warning-outline",
color: "#EF4444",
bg: "#FEF2F2",
badge: "Crítico",
},
MECHANICAL_FAILURE: {
severity: "critical",
icon: "warning",
color: "#EF4444",
bg: "#FEF2F2",
badge: "Crítico",
},
};
const STATUS_LABEL: Record<string, { label: string; color: string }> = {
EN_RUTA: { label: "ACTIVA", color: "#22C55E" },
DETENIDO: { label: "DETENIDO", color: "#F59E0B" },
FALLA: { label: "FALLA", color: "#EF4444" },
FINALIZADO: { label: "FINALIZADO", color: "#6B7280" },
ESPERA: { label: "EN ESPERA", color: "#6B7280" },
};
export default function AlertsScreen() { export default function AlertsScreen() {
const { user, notifications } = useApp(); const { user, notifications, route } = useApp();
if (!user) { if (!user) return <Redirect href="/login" />;
return <Redirect href="/login" />;
} const statusInfo =
STATUS_LABEL[route?.status ?? "ESPERA"] ?? STATUS_LABEL.ESPERA;
return ( return (
<SafeAreaView <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["top"]}
backgroundColor: COLORS.background,
}}
> >
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{ paddingBottom: 130 }}
padding: 20,
paddingBottom: 120,
}}
> >
<SectionTitle title="Alertas" /> {/* Hero con título + count + camion image en banner azul */}
<View style={styles.heroSection}>
<Text <View style={styles.heroTextWrap}>
style={{ <Text style={styles.headerTitle}>Alertas</Text>
color: "#6B7280", <Text style={styles.headerCount}>
fontSize: 14, {notifications.length}{" "}
marginBottom: 24, {notifications.length === 1
marginLeft: 12, ? "notificación"
}} : "notificaciones"}
>
{notifications.length > 0
? `${notifications.length} notificaciones`
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
</Text> </Text>
</View>
{notifications.map((n) => ( <View style={styles.heroBanner}>
<AlertItem <Image
key={n.id} source={require("../../assets/illustrations/truck-city.png")}
title={n.title} style={styles.truckImage}
description={n.body} resizeMode="cover"
time={new Date(n.createdAt).toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
})}
type={notificationTypeToAlertType(n.type)}
/> />
))} </View>
</View>
{/* Estado de ruta */}
<View
style={[
styles.statusBanner,
{ backgroundColor: `${statusInfo.color}15` },
]}
>
<View
style={[
styles.statusIcon,
{ backgroundColor: statusInfo.color },
]}
>
<Ionicons name="bus-outline" size={20} color="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.statusTitle, { color: statusInfo.color }]}>
ESTADO DE RUTA: {statusInfo.label}
</Text>
<Text style={styles.statusBody}>
{statusInfo.label === "ACTIVA"
? "El servicio de recolección sigue en curso."
: "Sin servicio activo en este momento."}
</Text>
</View>
</View>
{/* Timeline de notificaciones */}
{notifications.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="leaf-outline" size={40} color="#9CA3AF" />
<Text style={styles.emptyText}>
Aún no hay alertas. Las recibirás cuando el camión avance en
tu ruta.
</Text>
</View>
) : (
<View style={{ paddingHorizontal: 20, marginTop: 12 }}>
{notifications.map((n, idx) => {
const m = meta[n.type];
const isLast = idx === notifications.length - 1;
const timeStr = new Date(n.createdAt).toLocaleTimeString(
"es-MX",
{ hour: "2-digit", minute: "2-digit" },
);
return (
<View key={n.id} style={styles.timelineRow}>
{/* Columna izquierda: dot + línea */}
<View style={styles.timelineLeft}>
<View
style={[
styles.timelineDot,
{ backgroundColor: m.color },
]}
>
<Ionicons name={m.icon} size={14} color="#FFFFFF" />
</View>
{!isLast && (
<View
style={[
styles.timelineLine,
{ backgroundColor: `${m.color}50` },
]}
/>
)}
<Text style={styles.timelineTime}>{timeStr}</Text>
</View>
{/* Card */}
<View
style={[
styles.notifCard,
{ backgroundColor: m.bg },
]}
>
<View style={styles.notifHeader}>
<Text style={styles.notifTitle}>{n.title}</Text>
<View
style={[
styles.badge,
{ backgroundColor: `${m.color}25` },
]}
>
<Text style={[styles.badgeText, { color: m.color }]}>
{m.badge}
</Text>
</View>
</View>
<Text style={styles.notifBody}>{n.body}</Text>
</View>
</View>
);
})}
</View>
)}
{/* Consejo del día */}
<View style={styles.tipCard}>
<View style={styles.tipIcon}>
<Ionicons name="leaf" size={20} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.tipTitle}>Consejo del día</Text>
<Text style={styles.tipBody}>
Separa correctamente tus residuos. Tu acción hace la diferencia.
</Text>
</View>
<Image
source={require("../../assets/illustrations/bins-cute.png")}
style={styles.tipImage}
resizeMode="contain"
/>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({
heroSection: {
backgroundColor: "#DBEAFE",
paddingBottom: 0,
marginBottom: 20,
},
heroTextWrap: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
},
heroBanner: {
height: 150,
width: "100%",
backgroundColor: "#DBEAFE",
overflow: "hidden",
},
headerTitle: {
fontSize: 32,
fontWeight: "800",
color: "#0F172A",
},
headerCount: {
fontSize: 14,
color: "#6B7280",
marginTop: 2,
},
truckImage: {
width: "100%",
height: "100%",
},
statusBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
padding: 14,
borderRadius: 14,
marginBottom: 4,
},
statusIcon: {
width: 38,
height: 38,
borderRadius: 19,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
statusTitle: {
fontSize: 13,
fontWeight: "800",
},
statusBody: {
fontSize: 12,
color: "#374151",
marginTop: 2,
},
emptyBox: {
alignItems: "center",
padding: 40,
marginHorizontal: 20,
marginTop: 16,
backgroundColor: "#FFFFFF",
borderRadius: 14,
},
emptyText: {
fontSize: 13,
color: "#6B7280",
textAlign: "center",
marginTop: 10,
},
timelineRow: {
flexDirection: "row",
marginBottom: 14,
},
timelineLeft: {
width: 56,
alignItems: "center",
},
timelineDot: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
},
timelineLine: {
flex: 1,
width: 2,
marginTop: 2,
marginBottom: 2,
},
timelineTime: {
fontSize: 10,
color: "#9CA3AF",
marginTop: 4,
},
notifCard: {
flex: 1,
borderRadius: 14,
padding: 14,
marginLeft: 4,
},
notifHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 6,
},
notifTitle: {
fontSize: 14,
fontWeight: "800",
color: "#0F172A",
flex: 1,
paddingRight: 8,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 8,
},
badgeText: {
fontSize: 10,
fontWeight: "700",
},
notifBody: {
fontSize: 13,
color: "#374151",
lineHeight: 18,
},
tipCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 20,
marginTop: 16,
},
tipIcon: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
tipTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 4,
},
tipBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
tipImage: {
width: 70,
height: 70,
marginLeft: 8,
},
});

View File

@@ -1,15 +1,16 @@
import { ScrollView, View, Text, StyleSheet } from "react-native"; import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { COLORS } from "../constants/colors"; import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
type Category = { type Category = {
key: string; key: string;
title: string; title: string;
color: string; color: string;
bg: string;
icon: keyof typeof Ionicons.glyphMap; icon: keyof typeof Ionicons.glyphMap;
image: any;
examples: string[]; examples: string[];
tip: string; tip: string;
}; };
@@ -19,7 +20,9 @@ const CATEGORIES: Category[] = [
key: "organico", key: "organico",
title: "Orgánicos", title: "Orgánicos",
color: "#22C55E", color: "#22C55E",
bg: "#ECFDF5",
icon: "leaf", icon: "leaf",
image: require("../../assets/illustrations/organic-food.png"),
examples: [ examples: [
"Restos de comida (frutas, verduras, cáscaras)", "Restos de comida (frutas, verduras, cáscaras)",
"Bolsitas de té, residuos de café", "Bolsitas de té, residuos de café",
@@ -31,7 +34,9 @@ const CATEGORIES: Category[] = [
key: "reciclable", key: "reciclable",
title: "Reciclables", title: "Reciclables",
color: "#3B82F6", color: "#3B82F6",
bg: "#EFF6FF",
icon: "refresh-circle", icon: "refresh-circle",
image: require("../../assets/illustrations/pet-bottles.png"),
examples: [ examples: [
"Botellas y envases PET limpios", "Botellas y envases PET limpios",
"Cartón y papel seco", "Cartón y papel seco",
@@ -42,8 +47,10 @@ const CATEGORIES: Category[] = [
{ {
key: "sanitario", key: "sanitario",
title: "Sanitarios", title: "Sanitarios",
color: "#9CA3AF", color: "#6B7280",
bg: "#F3F4F6",
icon: "medkit", icon: "medkit",
image: require("../../assets/illustrations/sanitary.png"),
examples: [ examples: [
"Pañales y toallas femeninas", "Pañales y toallas femeninas",
"Papel higiénico usado", "Papel higiénico usado",
@@ -55,7 +62,9 @@ const CATEGORIES: Category[] = [
key: "especial", key: "especial",
title: "Especiales", title: "Especiales",
color: "#EF4444", color: "#EF4444",
bg: "#FEF2F2",
icon: "warning", icon: "warning",
image: require("../../assets/illustrations/battery-meds.png"),
examples: [ examples: [
"Pilas y baterías", "Pilas y baterías",
"Electrónicos viejos", "Electrónicos viejos",
@@ -68,39 +77,51 @@ const CATEGORIES: Category[] = [
export default function GuideScreen() { export default function GuideScreen() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}> <SafeAreaView
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["top"]}
>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }} contentContainerStyle={{ padding: 20, paddingBottom: 130 }}
>
<SectionTitle title="Guía de separación" />
<Text
style={{
color: "#6B7280",
fontSize: 14,
marginBottom: 20,
marginLeft: 12,
}}
> >
{/* Header con título grande + imagen */}
<View style={styles.headerRow}>
<View style={{ flex: 1, paddingRight: 8 }}>
<Text style={styles.headerTitle}>Guía de{"\n"}separación</Text>
<Text style={styles.headerSubtitle}>
Separar correctamente reduce contaminación y ayuda a la ruta de Separar correctamente reduce contaminación y ayuda a la ruta de
recolección. Funciona sin conexión. recolección.
</Text> </Text>
<Text style={styles.headerOffline}>Funciona sin conexión.</Text>
</View>
<Image
source={require("../../assets/illustrations/recycling.png")}
style={styles.heroImage}
resizeMode="contain"
/>
</View>
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<View key={cat.key} style={styles.card}> <View key={cat.key} style={styles.card}>
{/* Icono circular */}
<View <View
style={[ style={[
styles.iconWrap, styles.iconWrap,
{ backgroundColor: `${cat.color}20` }, { backgroundColor: cat.bg },
]} ]}
> >
<Ionicons name={cat.icon} size={28} color={cat.color} /> <Ionicons name={cat.icon} size={28} color={cat.color} />
</View> </View>
{/* Contenido */}
<View style={styles.cardContent}> <View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: cat.color }]}> <Text style={[styles.cardTitle, { color: cat.color }]}>
{cat.title} {cat.title}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
</View>
{cat.examples.map((ex, i) => ( {cat.examples.map((ex, i) => (
<Text key={i} style={styles.example}> <Text key={i} style={styles.example}>
{ex} {ex}
@@ -109,24 +130,50 @@ export default function GuideScreen() {
<View <View
style={[ style={[
styles.tipBox, styles.tipBox,
{ backgroundColor: `${cat.color}15` }, { backgroundColor: cat.bg },
]} ]}
> >
<Ionicons name="bulb" size={14} color={cat.color} />
<Text style={[styles.tipText, { color: cat.color }]}> <Text style={[styles.tipText, { color: cat.color }]}>
💡 {cat.tip} {cat.tip}
</Text> </Text>
</View> </View>
</View> </View>
{/* Imagen ilustrativa por categoría */}
<Image
source={cat.image}
style={styles.categoryImage}
resizeMode="contain"
/>
</View> </View>
))} ))}
{/* Recuerda */}
<View style={styles.preventiveBox}> <View style={styles.preventiveBox}>
<View style={{ flex: 1 }}>
<View style={styles.preventiveHeader}>
<View style={styles.preventiveIcon}>
<Ionicons name="notifications" size={20} color="#FFFFFF" />
</View>
<Text style={styles.preventiveTitle}>Recuerda</Text> <Text style={styles.preventiveTitle}>Recuerda</Text>
<Text style={styles.preventiveBody}> </View>
Saca tu basura sólo dentro del horario de recolección.{"\n"} {[
No persigas al camión, es peligroso.{"\n"} "Saca tu basura sólo dentro del horario.",
Si tu camión no pasó, usa el buzón de retroalimentación. "No persigas al camión, es peligroso.",
</Text> "Si tu camión no pasó, usa el buzón.",
].map((t, i) => (
<View key={i} style={styles.preventiveRow}>
<Ionicons name="checkmark-circle" size={14} color="#92400E" />
<Text style={styles.preventiveBody}>{t}</Text>
</View>
))}
</View>
<Image
source={require("../../assets/illustrations/truck-banner.png")}
style={styles.preventiveImage}
resizeMode="contain"
/>
</View> </View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
@@ -134,11 +181,40 @@ export default function GuideScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
headerRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 20,
paddingHorizontal: 4,
},
headerTitle: {
fontSize: 30,
fontWeight: "800",
color: "#0F172A",
lineHeight: 34,
marginBottom: 8,
},
headerSubtitle: {
fontSize: 13,
color: "#1E40AF",
lineHeight: 18,
marginBottom: 4,
},
headerOffline: {
fontSize: 13,
color: "#0E8A61",
fontWeight: "700",
},
heroImage: {
width: 120,
height: 120,
},
card: { card: {
flexDirection: "row", flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
borderRadius: 18, borderRadius: 18,
padding: 16, padding: 14,
marginBottom: 14, marginBottom: 14,
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
@@ -152,28 +228,82 @@ const styles = StyleSheet.create({
borderRadius: 25, borderRadius: 25,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: 14, marginRight: 12,
}, },
cardContent: { flex: 1 }, cardContent: {
cardTitle: { fontSize: 17, fontWeight: "800", marginBottom: 6 }, flex: 1,
example: { fontSize: 13, color: "#4B5563", marginBottom: 3 }, paddingRight: 4,
},
cardTitleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
},
cardTitle: { fontSize: 18, fontWeight: "800" },
example: { fontSize: 12, color: "#4B5563", marginBottom: 2 },
tipBox: { tipBox: {
marginTop: 10, flexDirection: "row",
padding: 10, alignItems: "center",
borderRadius: 10,
},
tipText: { fontSize: 12, fontWeight: "600" },
preventiveBox: {
marginTop: 8, marginTop: 8,
padding: 16, padding: 8,
borderRadius: 8,
},
tipText: {
fontSize: 11,
fontWeight: "600",
marginLeft: 6,
flex: 1,
lineHeight: 14,
},
categoryImage: {
width: 64,
height: 80,
marginLeft: 4,
alignSelf: "center",
},
preventiveBox: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
padding: 14,
backgroundColor: "#FEF3C7", backgroundColor: "#FEF3C7",
borderRadius: 14, borderRadius: 14,
}, },
preventiveHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
preventiveIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#F59E0B",
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
preventiveTitle: { preventiveTitle: {
fontSize: 14, fontSize: 16,
fontWeight: "800", fontWeight: "800",
color: "#92400E", color: "#92400E",
marginBottom: 6,
}, },
preventiveBody: { fontSize: 13, color: "#92400E", lineHeight: 20 }, preventiveRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
},
preventiveBody: {
fontSize: 12,
color: "#92400E",
marginLeft: 6,
flex: 1,
lineHeight: 16,
},
preventiveImage: {
width: 90,
height: 90,
marginLeft: 4,
},
}); });

View File

@@ -1,127 +1,473 @@
import { ScrollView, View, Text, RefreshControl } from "react-native"; import { useEffect } from "react";
import {
ScrollView,
View,
Text,
Image,
Pressable,
StyleSheet,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router"; import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors"; import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import EtaCard from "../components/EtaCard";
import QuickAction from "../components/QuickAction";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
type QuickAction = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
bg: string;
onPress: () => void;
};
export default function HomeScreen() { export default function HomeScreen() {
const { user, eta, route, loading, refreshStatus } = useApp(); const { user, eta, route, loading, refreshStatus } = useApp();
const router = useRouter(); const router = useRouter();
if (!user) { useEffect(() => {
return <Redirect href="/login" />; if (user && !eta) {
void refreshStatus();
} }
}, [user]);
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0; if (!user) return <Redirect href="/login" />;
const minutes = eta ? Math.max(0, eta.etaMinutes) : null;
const windowText = eta?.arrivalWindow const windowText = eta?.arrivalWindow
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}` ? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.`
: "Esperando datos del camión..."; : "Calculando...";
const subtitle = const statusOK = route?.status === "EN_RUTA" || route?.status === "ESPERA";
eta?.message ?? `Monitoreando ruta ${route?.routeId ?? "..."}`; const statusColor = statusOK ? "#22C55E" : "#EF4444";
const statusLabel =
route?.status === "EN_RUTA"
? "ESTABLE"
: route?.status === "FALLA"
? "CON FALLA"
: route?.status === "DETENIDO"
? "DETENIDO"
: "EN ESPERA";
const quickActions: QuickAction[] = [
{
key: "alerts",
title: "Alertas",
icon: "notifications-outline",
color: "#22C55E",
bg: "#ECFDF5",
onPress: () => router.push("/alerts"),
},
{
key: "guide",
title: "Guía",
icon: "leaf-outline",
color: "#3B82F6",
bg: "#EFF6FF",
onPress: () => router.push("/guide"),
},
{
key: "feedback",
title: "Reportar",
icon: "chatbubble-outline",
color: "#EF4444",
bg: "#FEF2F2",
onPress: () => router.push("/feedback"),
},
{
key: "address",
title: "Domicilio",
icon: "home-outline",
color: "#8B5CF6",
bg: "#F5F3FF",
onPress: () => router.push("/addresses"),
},
];
return ( return (
<SafeAreaView <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["top"]}
backgroundColor: COLORS.background,
}}
> >
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{ paddingBottom: 130 }}
padding: 20, refreshControl={undefined}
paddingBottom: 120,
}}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refreshStatus} />
}
> >
<SectionTitle title="EcoRuta" /> {/* Title */}
<View style={styles.titleRow}>
<Text <View>
style={{ <Text style={styles.title}>EcoRuta</Text>
color: "#6B7280", <Text style={styles.subtitle}>
fontSize: 14, Ruta {route?.routeId ?? "—"}
marginBottom: 20,
marginLeft: 12,
}}
>
{subtitle}
</Text>
<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> </Text>
</View> </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> </View>
<SectionTitle title="Acciones rápidas" /> {/* Hero del camión */}
<View style={styles.heroBox}>
<View <Image
style={{ source={require("../../assets/illustrations/truck-city.png")}
flexDirection: "row", style={styles.heroTruck}
justifyContent: "space-between", resizeMode="cover"
marginTop: 10,
}}
>
<QuickAction
title="Alertas"
icon="notifications-outline"
onPress={() => router.push("/alerts")}
/> />
</View>
<QuickAction {/* Card ETA */}
title="Reportar" <View style={styles.etaCard}>
icon="chatbubble-outline" <View style={styles.etaContent}>
onPress={() => router.push("/feedback")} <View style={styles.etaIcon}>
<Ionicons name="time-outline" size={22} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.etaLabel}>LLEGADA ESTIMADA</Text>
<Text style={styles.etaMinutes}>
{minutes !== null
? `${minutes} min`
: loading
? "..."
: "—"}
</Text>
<Text style={styles.etaWindowLabel}>Ventana de recolección</Text>
<View style={styles.windowPill}>
<Text style={styles.windowText}>{windowText}</Text>
</View>
</View>
<View style={styles.etaCircle}>
<View style={styles.etaCircleInner}>
<Ionicons name="bus-outline" size={28} color="#0E8A61" />
</View>
</View>
</View>
</View>
{/* Estado de ruta */}
<View style={styles.statusBanner}>
<View
style={[
styles.statusDot,
{ backgroundColor: statusColor },
]}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.statusTitle, { color: statusColor }]}>
ESTADO DE RUTA: {statusLabel}
</Text>
<Text style={styles.statusBody}>
{statusOK
? "Tu recolección sigue en horario normal."
: "Hay incidencias en la ruta."}
</Text>
</View>
<View
style={[
styles.statusShield,
{ backgroundColor: `${statusColor}15` },
]}
>
<Ionicons
name="shield-checkmark-outline"
size={20}
color={statusColor}
/>
</View>
</View>
{/* Mensaje preventivo */}
<View style={styles.warningBanner}>
<View style={styles.warningIcon}>
<Ionicons name="alert" size={20} color="#FFFFFF" />
</View>
<Text style={styles.warningText}>
No saques tus residuos fuera del horario{"\n"}y no persigas al camión.
</Text>
<View style={styles.warningRight}>
<Ionicons name="walk-outline" size={20} color="#92400E" />
</View>
</View>
{/* Acciones rápidas */}
<Text style={styles.sectionTitle}>ACCIONES RÁPIDAS</Text>
<View style={styles.actionsGrid}>
{quickActions.map((a) => (
<Pressable
key={a.key}
style={styles.actionCard}
onPress={a.onPress}
>
<View style={[styles.actionIcon, { backgroundColor: a.bg }]}>
<Ionicons name={a.icon} size={26} color={a.color} />
</View>
<Text style={styles.actionTitle}>{a.title}</Text>
</Pressable>
))}
</View>
{/* Consejo rápido */}
<View style={styles.tipCard}>
<View style={styles.tipIcon}>
<Ionicons name="leaf" size={22} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.tipTitle}>Consejo rápido</Text>
<Text style={styles.tipBody}>
Compacta cartón y PET para ahorrar espacio y ayudar al medio
ambiente.
</Text>
</View>
<Image
source={require("../../assets/illustrations/pet-bottles.png")}
style={styles.tipImage}
resizeMode="contain"
/> />
</View> </View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({
titleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 8,
},
title: {
fontSize: 32,
fontWeight: "800",
color: "#0E8A61",
},
subtitle: {
fontSize: 14,
color: "#6B7280",
marginTop: 2,
},
heroBox: {
height: 180,
marginTop: 8,
backgroundColor: "#DBEAFE",
overflow: "hidden",
},
heroTruck: {
width: "100%",
height: "100%",
},
etaCard: {
marginHorizontal: 20,
marginTop: -30,
backgroundColor: "#ECFDF5",
borderRadius: 20,
padding: 18,
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 10,
elevation: 6,
},
etaContent: {
flexDirection: "row",
alignItems: "center",
},
etaIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
etaLabel: {
fontSize: 11,
fontWeight: "800",
color: "#065F46",
letterSpacing: 0.5,
},
etaMinutes: {
fontSize: 36,
fontWeight: "800",
color: "#0F172A",
marginTop: 4,
lineHeight: 38,
},
etaWindowLabel: {
fontSize: 11,
color: "#6B7280",
marginTop: 6,
},
windowPill: {
alignSelf: "flex-start",
backgroundColor: "#D1FAE5",
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 4,
marginTop: 4,
},
windowText: {
fontSize: 13,
color: "#065F46",
fontWeight: "700",
},
etaCircle: {
width: 88,
height: 88,
borderRadius: 44,
borderWidth: 4,
borderColor: "#22C55E",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
etaCircleInner: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
},
statusBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
marginTop: 14,
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
statusDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
statusTitle: {
fontSize: 13,
fontWeight: "800",
},
statusBody: {
fontSize: 12,
color: "#374151",
marginTop: 2,
},
statusShield: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
warningBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
marginTop: 12,
backgroundColor: "#FEF3C7",
borderRadius: 14,
padding: 12,
},
warningIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#F59E0B",
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
warningText: {
flex: 1,
fontSize: 12,
color: "#92400E",
fontWeight: "600",
lineHeight: 16,
},
warningRight: {
marginLeft: 8,
},
sectionTitle: {
fontSize: 12,
fontWeight: "800",
color: "#6B7280",
letterSpacing: 1,
marginTop: 22,
marginBottom: 10,
marginLeft: 20,
},
actionsGrid: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 20,
},
actionCard: {
flex: 1,
backgroundColor: "#FFFFFF",
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
marginHorizontal: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
actionIcon: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: "center",
alignItems: "center",
marginBottom: 8,
},
actionTitle: {
fontSize: 12,
fontWeight: "700",
color: "#0F172A",
textAlign: "center",
},
tipCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 20,
marginTop: 16,
},
tipIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
tipTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 2,
},
tipBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
tipImage: {
width: 60,
height: 60,
marginLeft: 8,
},
});

View File

@@ -1,104 +1,382 @@
import { View, Text, StyleSheet, Alert } from "react-native"; import { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
Image,
Pressable,
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 { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors"; import { COLORS } from "../constants/colors";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
import { resetDemo } from "../services/tracking.service"; import { getMyAddress, type MyAddress } from "../services/addresses.service";
import { apiFetch } from "../lib/api";
export default function ProfileScreen() { type Row = {
const { user, logout, refreshStatus } = useApp(); key: string;
const router = useRouter(); title: string;
subtitle: string;
const handleResetDemo = async () => { icon: keyof typeof Ionicons.glyphMap;
try { color: string;
await resetDemo(); onPress: () => void;
await refreshStatus(); danger?: boolean;
Alert.alert("Demo reiniciado", "Estado y notificaciones borradas.");
} catch (err) {
Alert.alert(
"Error",
err instanceof Error ? err.message : "No se pudo reiniciar",
);
}
}; };
if (!user) { export default function ProfileScreen() {
return ( const { user, logout } = useApp();
<SafeAreaView style={styles.container}> const router = useRouter();
<View style={styles.content}> const [myAddress, setMyAddress] = useState<MyAddress | null>(null);
<Text style={styles.title}>No has iniciado sesión</Text>
<PrimaryButton useEffect(() => {
title="Iniciar sesión" if (!user) return;
onPress={() => router.push("/login")} void getMyAddress().then(setMyAddress).catch(() => {});
/> }, [user]);
</View>
</SafeAreaView> if (!user) return <Redirect href="/login" />;
);
}
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
router.replace("/login"); router.replace("/login");
}; };
const handleReset = async () => {
try {
await apiFetch<{ message: string }>("/api/tracking/reset-demo", {
method: "POST",
});
Alert.alert("Demo reiniciada", "Las notificaciones se borraron.");
} catch (err) {
Alert.alert(
"No se pudo reiniciar",
err instanceof Error ? err.message : "Error",
);
}
};
const handleHelp = () => {
Alert.alert(
"Ayuda",
"EcoRuta te avisa cuando el camión recolector está cerca.\n\n" +
"• Tu dirección define la ruta que verás.\n" +
"• Las alertas se actualizan cada 30 segundos.\n" +
"• Usa el buzón si el camión no pasó.",
);
};
const rows: Row[] = [
{
key: "address",
title: "Mi domicilio",
subtitle: "Ver y administrar tus domicilios registrados",
icon: "home-outline",
color: "#0E8A61",
onPress: () => router.push("/addresses"),
},
{
key: "feedback",
title: "Buzón de retroalimentación",
subtitle: "Sugerencias, comentarios y reportes",
icon: "chatbubble-outline",
color: "#0E8A61",
onPress: () => router.push("/feedback"),
},
{
key: "reset",
title: "Reiniciar demo",
subtitle: "Restablecer la simulación y los datos",
icon: "refresh-outline",
color: "#0E8A61",
onPress: handleReset,
},
{
key: "help",
title: "Ayuda",
subtitle: "Preguntas frecuentes y soporte",
icon: "help-circle-outline",
color: "#0E8A61",
onPress: handleHelp,
},
{
key: "logout",
title: "Cerrar sesión",
subtitle: "Salir de tu cuenta",
icon: "log-out-outline",
color: "#EF4444",
onPress: handleLogout,
danger: true,
},
];
const initial = (user.name?.[0] ?? user.email[0]).toUpperCase();
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView
<View style={styles.content}> style={{ flex: 1, backgroundColor: COLORS.background }}
<Text style={styles.title}>Hola, {user.name}</Text> edges={["bottom"]}
<Text style={styles.email}>{user.email}</Text> >
<ScrollView
<View style={{ height: 24 }} /> showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
<PrimaryButton >
title="Mi domicilio" {/* Header verde con avatar */}
onPress={() => router.push("/addresses")} <View style={styles.heroWrap}>
/> <View style={styles.heroBackground} />
<View style={styles.avatarOuter}>
<View style={{ height: 12 }} /> <View style={styles.avatarInner}>
<Text style={styles.avatarText}>{initial}</Text>
<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} />
</View> </View>
</View>
</View>
<View style={styles.identityBox}>
<Text style={styles.name}>
Hola, {user.name} <Text style={{ color: "#22C55E" }}>🌿</Text>
</Text>
<View style={styles.emailRow}>
<Ionicons name="mail-outline" size={14} color="#6B7280" />
<Text style={styles.email}>{user.email}</Text>
</View>
</View>
{/* Zona + ruta */}
<Pressable
style={styles.zoneRow}
onPress={() => router.push("/addresses")}
>
<View style={styles.zoneCol}>
<Ionicons name="location-outline" size={22} color="#0E8A61" />
<View style={{ marginLeft: 8, flex: 1 }}>
<Text style={styles.zoneLabel}>Tu zona</Text>
<Text style={styles.zoneValue} numberOfLines={2}>
{myAddress?.colonia ?? "Sin asignar"}
</Text>
</View>
</View>
<View style={styles.zoneDivider} />
<View style={styles.zoneCol}>
<Ionicons name="bus-outline" size={22} color="#0E8A61" />
<View style={{ marginLeft: 8, flex: 1 }}>
<Text style={styles.zoneLabel}>Ruta asignada</Text>
<Text style={styles.zoneValue} numberOfLines={1}>
{myAddress?.routeId ?? "—"}
</Text>
</View>
</View>
<Ionicons
name="chevron-forward"
size={18}
color="#9CA3AF"
style={{ marginLeft: 4 }}
/>
</Pressable>
{/* Lista de opciones */}
<View style={{ paddingHorizontal: 16, marginTop: 4 }}>
{rows.map((r) => (
<Pressable key={r.key} style={styles.rowCard} onPress={r.onPress}>
<View
style={[
styles.rowIcon,
{ backgroundColor: `${r.color}15` },
]}
>
<Ionicons name={r.icon} size={22} color={r.color} />
</View>
<View style={{ flex: 1 }}>
<Text
style={[
styles.rowTitle,
r.danger && { color: "#EF4444" },
]}
>
{r.title}
</Text>
<Text style={styles.rowSubtitle}>{r.subtitle}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
</Pressable>
))}
</View>
{/* Tu impacto cuenta */}
<View style={styles.impactCard}>
<View style={styles.impactLeaf}>
<Image
source={require("../../assets/illustrations/impact-leaf.png")}
style={{ width: 40, height: 40 }}
resizeMode="contain"
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.impactTitle}>Tu impacto cuenta</Text>
<Text style={styles.impactBody}>
Siguiendo los horarios y separando correctamente, haces tu ciudad
más limpia.
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { heroWrap: {
flex: 1, height: 140,
backgroundColor: COLORS.background, backgroundColor: "#D1FAE5",
overflow: "visible",
marginBottom: 60,
}, },
content: { heroBackground: {
flex: 1, position: "absolute",
padding: 24, top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#D1FAE5",
},
avatarOuter: {
position: "absolute",
bottom: -50,
alignSelf: "center",
width: 112,
height: 112,
borderRadius: 56,
backgroundColor: "#FFFFFF",
justifyContent: "center", justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 6,
}, },
title: { avatarInner: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: "#0E8A61",
justifyContent: "center",
alignItems: "center",
},
avatarText: {
color: "#FFFFFF",
fontSize: 38,
fontWeight: "800",
},
identityBox: {
alignItems: "center",
marginBottom: 18,
},
name: {
fontSize: 22, fontSize: 22,
fontWeight: "bold", fontWeight: "800",
textAlign: "center", color: "#0F172A",
marginBottom: 6, },
emailRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 6,
}, },
email: { email: {
fontSize: 14, fontSize: 13,
color: "#6B7280", color: "#6B7280",
textAlign: "center", marginLeft: 6,
},
zoneRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 16,
marginBottom: 16, marginBottom: 16,
}, },
zoneCol: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
zoneDivider: {
width: 1,
height: 36,
backgroundColor: "#A7F3D0",
marginHorizontal: 8,
},
zoneLabel: {
fontSize: 11,
color: "#065F46",
fontWeight: "700",
},
zoneValue: {
fontSize: 13,
color: "#0F172A",
fontWeight: "600",
marginTop: 2,
},
rowCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 2,
},
rowIcon: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
rowTitle: {
fontSize: 15,
fontWeight: "700",
color: "#0F172A",
},
rowSubtitle: {
fontSize: 12,
color: "#6B7280",
marginTop: 2,
},
impactCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 16,
marginTop: 8,
},
impactLeaf: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
impactTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 4,
},
impactBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
}); });

View File

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