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 { Ionicons } from "@expo/vector-icons";
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";
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() {
const { user, notifications } = useApp();
const { user, notifications, route } = useApp();
if (!user) {
return <Redirect href="/login" />;
}
if (!user) return <Redirect href="/login" />;
return (
<SafeAreaView
style={{
flex: 1,
backgroundColor: COLORS.background,
}}
const statusInfo =
STATUS_LABEL[route?.status ?? "ESPERA"] ?? STATUS_LABEL.ESPERA;
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["top"]}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
>
{/* Hero con título + count + camion image en banner azul */}
<View style={styles.heroSection}>
<View style={styles.heroTextWrap}>
<Text style={styles.headerTitle}>Alertas</Text>
<Text style={styles.headerCount}>
{notifications.length}{" "}
{notifications.length === 1
? "notificación"
: "notificaciones"}
</Text>
</View>
<View style={styles.heroBanner}>
<Image
source={require("../../assets/illustrations/truck-city.png")}
style={styles.truckImage}
resizeMode="cover"
/>
</View>
</View>
{/* Estado de ruta */}
<View
style={[
styles.statusBanner,
{ backgroundColor: `${statusInfo.color}15` },
]}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 20,
paddingBottom: 120,
}}
>
<SectionTitle title="Alertas" />
<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>
<Text
style={{
color: "#6B7280",
fontSize: 14,
marginBottom: 24,
marginLeft: 12,
}}
>
{notifications.length > 0
? `${notifications.length} notificaciones`
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
</Text>
{/* 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>
{notifications.map((n) => (
<AlertItem
key={n.id}
title={n.title}
description={n.body}
time={new Date(n.createdAt).toLocaleTimeString("es-MX", {
hour: "2-digit",
minute: "2-digit",
})}
type={notificationTypeToAlertType(n.type)}
/>
))}
</ScrollView>
</SafeAreaView>
);
{/* 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>
</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 { Ionicons } from "@expo/vector-icons";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
type Category = {
key: string;
title: string;
color: string;
bg: string;
icon: keyof typeof Ionicons.glyphMap;
image: any;
examples: string[];
tip: string;
};
@@ -19,7 +20,9 @@ const CATEGORIES: Category[] = [
key: "organico",
title: "Orgánicos",
color: "#22C55E",
bg: "#ECFDF5",
icon: "leaf",
image: require("../../assets/illustrations/organic-food.png"),
examples: [
"Restos de comida (frutas, verduras, cáscaras)",
"Bolsitas de té, residuos de café",
@@ -31,7 +34,9 @@ const CATEGORIES: Category[] = [
key: "reciclable",
title: "Reciclables",
color: "#3B82F6",
bg: "#EFF6FF",
icon: "refresh-circle",
image: require("../../assets/illustrations/pet-bottles.png"),
examples: [
"Botellas y envases PET limpios",
"Cartón y papel seco",
@@ -42,8 +47,10 @@ const CATEGORIES: Category[] = [
{
key: "sanitario",
title: "Sanitarios",
color: "#9CA3AF",
color: "#6B7280",
bg: "#F3F4F6",
icon: "medkit",
image: require("../../assets/illustrations/sanitary.png"),
examples: [
"Pañales y toallas femeninas",
"Papel higiénico usado",
@@ -55,7 +62,9 @@ const CATEGORIES: Category[] = [
key: "especial",
title: "Especiales",
color: "#EF4444",
bg: "#FEF2F2",
icon: "warning",
image: require("../../assets/illustrations/battery-meds.png"),
examples: [
"Pilas y baterías",
"Electrónicos viejos",
@@ -68,39 +77,51 @@ const CATEGORIES: Category[] = [
export default function GuideScreen() {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
<SafeAreaView
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["top"]}
>
<ScrollView
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,
}}
>
Separar correctamente reduce contaminación y ayuda a la ruta de
recolección. Funciona sin conexión.
</Text>
{/* 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
recolección.
</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) => (
<View key={cat.key} style={styles.card}>
{/* Icono circular */}
<View
style={[
styles.iconWrap,
{ backgroundColor: `${cat.color}20` },
{ backgroundColor: cat.bg },
]}
>
<Ionicons name={cat.icon} size={28} color={cat.color} />
</View>
{/* Contenido */}
<View style={styles.cardContent}>
<Text style={[styles.cardTitle, { color: cat.color }]}>
{cat.title}
</Text>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: cat.color }]}>
{cat.title}
</Text>
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
</View>
{cat.examples.map((ex, i) => (
<Text key={i} style={styles.example}>
{ex}
@@ -109,24 +130,50 @@ export default function GuideScreen() {
<View
style={[
styles.tipBox,
{ backgroundColor: `${cat.color}15` },
{ backgroundColor: cat.bg },
]}
>
<Ionicons name="bulb" size={14} color={cat.color} />
<Text style={[styles.tipText, { color: cat.color }]}>
💡 {cat.tip}
{cat.tip}
</Text>
</View>
</View>
{/* Imagen ilustrativa por categoría */}
<Image
source={cat.image}
style={styles.categoryImage}
resizeMode="contain"
/>
</View>
))}
{/* Recuerda */}
<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 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>
</View>
{[
"Saca tu basura sólo dentro del horario.",
"No persigas al camión, es peligroso.",
"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>
</ScrollView>
</SafeAreaView>
@@ -134,11 +181,40 @@ export default function GuideScreen() {
}
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: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#FFFFFF",
borderRadius: 18,
padding: 16,
padding: 14,
marginBottom: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
@@ -152,28 +228,82 @@ const styles = StyleSheet.create({
borderRadius: 25,
justifyContent: "center",
alignItems: "center",
marginRight: 14,
marginRight: 12,
},
cardContent: { flex: 1 },
cardTitle: { fontSize: 17, fontWeight: "800", marginBottom: 6 },
example: { fontSize: 13, color: "#4B5563", marginBottom: 3 },
cardContent: {
flex: 1,
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: {
marginTop: 10,
padding: 10,
borderRadius: 10,
},
tipText: { fontSize: 12, fontWeight: "600" },
preventiveBox: {
flexDirection: "row",
alignItems: "center",
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",
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: {
fontSize: 14,
fontSize: 16,
fontWeight: "800",
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 { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
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";
type QuickAction = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
bg: string;
onPress: () => void;
};
export default function HomeScreen() {
const { user, eta, route, loading, refreshStatus } = useApp();
const router = useRouter();
if (!user) {
return <Redirect href="/login" />;
}
useEffect(() => {
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
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
: "Esperando datos del camión...";
? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.`
: "Calculando...";
const subtitle =
eta?.message ?? `Monitoreando ruta ${route?.routeId ?? "..."}`;
const statusOK = route?.status === "EN_RUTA" || route?.status === "ESPERA";
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 (
<SafeAreaView
style={{
flex: 1,
backgroundColor: COLORS.background,
}}
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["top"]}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 20,
paddingBottom: 120,
}}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refreshStatus} />
}
contentContainerStyle={{ paddingBottom: 130 }}
refreshControl={undefined}
>
<SectionTitle title="EcoRuta" />
<Text
style={{
color: "#6B7280",
fontSize: 14,
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}
{/* Title */}
<View style={styles.titleRow}>
<View>
<Text style={styles.title}>EcoRuta</Text>
<Text style={styles.subtitle}>
Ruta {route?.routeId ?? "—"}
</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" />
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginTop: 10,
}}
>
<QuickAction
title="Alertas"
icon="notifications-outline"
onPress={() => router.push("/alerts")}
{/* Hero del camión */}
<View style={styles.heroBox}>
<Image
source={require("../../assets/illustrations/truck-city.png")}
style={styles.heroTruck}
resizeMode="cover"
/>
</View>
<QuickAction
title="Reportar"
icon="chatbubble-outline"
onPress={() => router.push("/feedback")}
{/* Card ETA */}
<View style={styles.etaCard}>
<View style={styles.etaContent}>
<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>
</ScrollView>
</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 { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
import { resetDemo } from "../services/tracking.service";
import { getMyAddress, type MyAddress } from "../services/addresses.service";
import { apiFetch } from "../lib/api";
type Row = {
key: string;
title: string;
subtitle: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
onPress: () => void;
danger?: boolean;
};
export default function ProfileScreen() {
const { user, logout, refreshStatus } = useApp();
const router = useRouter();
const { user, logout } = useApp();
const router = useRouter();
const [myAddress, setMyAddress] = useState<MyAddress | null>(null);
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",
);
}
};
useEffect(() => {
if (!user) return;
void getMyAddress().then(setMyAddress).catch(() => {});
}, [user]);
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>
);
if (!user) return <Redirect href="/login" />;
const handleLogout = () => {
logout();
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 handleLogout = () => {
logout();
router.replace("/login");
};
return (
<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="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} />
</View>
</SafeAreaView>
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 (
<SafeAreaView
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["bottom"]}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
>
{/* Header verde con avatar */}
<View style={styles.heroWrap}>
<View style={styles.heroBackground} />
<View style={styles.avatarOuter}>
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>{initial}</Text>
</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>
);
}
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,
},
heroWrap: {
height: 140,
backgroundColor: "#D1FAE5",
overflow: "visible",
marginBottom: 60,
},
heroBackground: {
position: "absolute",
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",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 6,
},
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,
fontWeight: "800",
color: "#0F172A",
},
emailRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 6,
},
email: {
fontSize: 13,
color: "#6B7280",
marginLeft: 6,
},
zoneRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 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
// 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://10.0.2.2:8080";
//export const API_URL = "http://172.20.10.4:8080";