feat: configuracion de alertas, WebSocket con fallback polling

This commit is contained in:
2026-05-23 01:17:49 -06:00
parent 51fbe69e13
commit dcf3f83f02

View File

@@ -2,8 +2,6 @@ import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View, TextInput, TouchableOpacity, ActivityIndicator, ScrollView, Alert, RefreshControl } from 'react-native'; import { StyleSheet, Text, View, TextInput, TouchableOpacity, ActivityIndicator, ScrollView, Alert, RefreshControl } from 'react-native';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
const API_URL = 'http://10.137.112.65:8000'; const API_URL = 'http://10.137.112.65:8000';
@@ -11,13 +9,6 @@ const COLONIAS = [
'Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico', 'Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico',
'Los Olivos', 'Rancho Seco', 'Las Insurgentes' 'Los Olivos', 'Rancho Seco', 'Las Insurgentes'
]; ];
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export default function App() { export default function App() {
const [screen, setScreen] = useState('splash'); const [screen, setScreen] = useState('splash');
@@ -35,25 +26,43 @@ export default function App() {
const [mostrarColonias, setMostrarColonias] = useState(false); const [mostrarColonias, setMostrarColonias] = useState(false);
const [direccion, setDireccion] = useState(''); const [direccion, setDireccion] = useState('');
const [codigoPostal, setCodigoPostal] = useState(''); const [codigoPostal, setCodigoPostal] = useState('');
const [notifConfig, setNotifConfig] = useState({
routeStart: true,
proximity: true,
completed: true,
});
useEffect(() => { useEffect(() => {
cargarSesion(); cargarSesion();
registrarNotificaciones();
}, []); }, []);
useEffect(() => { useEffect(() => {
let ws = null;
let interval = null;
if (screen === 'eta' && domicilioActivo && token) { if (screen === 'eta' && domicilioActivo && token) {
const interval = setInterval(() => { ws = conectarWebSocket(domicilioActivo.id, token);
interval = setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
consultarETA(domicilioActivo.id, token, true); consultarETA(domicilioActivo.id, token, true);
}, 120000);
return () => clearInterval(interval);
} }
}, 120000);
}
return () => {
if (ws) ws.close();
if (interval) clearInterval(interval);
};
}, [screen, domicilioActivo, token]); }, [screen, domicilioActivo, token]);
const enviarNotificacionLocal = async (titulo, cuerpo) => {
Alert.alert(titulo, cuerpo);
};
const cargarSesion = async () => { const cargarSesion = async () => {
try { try {
const t = await AsyncStorage.getItem('token'); const t = await AsyncStorage.getItem('token');
const dId = await AsyncStorage.getItem('domicilioId'); const dId = await AsyncStorage.getItem('domicilioId');
const config = await AsyncStorage.getItem('notifConfig');
if (config) setNotifConfig(JSON.parse(config));
if (t) { if (t) {
setToken(t); setToken(t);
const doms = await cargarDomicilios(t); const doms = await cargarDomicilios(t);
@@ -87,6 +96,7 @@ useEffect(() => {
setToken(null); setDomicilios([]); setDomicilioActivo(null); setToken(null); setDomicilios([]); setDomicilioActivo(null);
setEta(null); setEmail(''); setPassword(''); setTelefono(''); setEta(null); setEmail(''); setPassword(''); setTelefono('');
setDireccion(''); setColoniaSeleccionada(''); setCodigoPostal(''); setDireccion(''); setColoniaSeleccionada(''); setCodigoPostal('');
setNotifConfig({ routeStart: true, proximity: true, completed: true });
setScreen('login'); setScreen('login');
}; };
@@ -180,23 +190,17 @@ useEffect(() => {
}); });
const data = await res.json(); const data = await res.json();
if (data.mensaje) { if (data.mensaje) {
if (data.evento === 'TRUCK_PROXIMITY' && eta?.evento !== 'TRUCK_PROXIMITY') { if (data.evento === 'TRUCK_PROXIMITY' && eta?.evento !== 'TRUCK_PROXIMITY' && notifConfig.proximity) {
enviarNotificacionLocal( enviarNotificacionLocal('🚨 ¡Camión cercano!',
'🚨 ¡Camión cercano!', `El camión está a menos de 15 minutos de ${data.colonia}. Saca tus bolsas a la acera.`);
`El camión está a menos de 15 minutos de ${data.colonia}. Saca tus bolsas a la acera.`
);
} }
if (data.evento === 'ROUTE_START' && eta?.evento !== 'ROUTE_START') { if (data.evento === 'ROUTE_START' && eta?.evento !== 'ROUTE_START' && notifConfig.routeStart) {
enviarNotificacionLocal( enviarNotificacionLocal('🟢 Ruta iniciada',
'🟢 Ruta iniciada', `El camión de ${data.colonia} ha salido. Prepara tus residuos.`);
`El camión de ${data.colonia} ha salido. Prepara tus residuos.`
);
} }
if (data.evento === 'ROUTE_COMPLETED' && eta?.evento !== 'ROUTE_COMPLETED') { if (data.evento === 'ROUTE_COMPLETED' && eta?.evento !== 'ROUTE_COMPLETED' && notifConfig.completed) {
enviarNotificacionLocal( enviarNotificacionLocal('✅ Servicio finalizado',
'✅ Servicio finalizado', `El camión de ${data.colonia} ha concluido su jornada.`);
`El camión de ${data.colonia} ha concluido su jornada.`
);
} }
setEta(data); setEta(data);
} }
@@ -210,25 +214,37 @@ useEffect(() => {
setRefreshing(false); setRefreshing(false);
}, [domicilioActivo, token]); }, [domicilioActivo, token]);
const registrarNotificaciones = async () => {
if (!Device.isDevice) return;
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
};
const enviarNotificacionLocal = async (titulo, cuerpo) => {
await Notifications.scheduleNotificationAsync({
content: { title: titulo, body: cuerpo, sound: true },
trigger: null,
});
};
const seleccionarDomicilio = async (dom) => { const seleccionarDomicilio = async (dom) => {
setDomicilioActivo(dom); setDomicilioActivo(dom);
await AsyncStorage.setItem('domicilioId', String(dom.id)); await AsyncStorage.setItem('domicilioId', String(dom.id));
consultarETA(dom.id, token, true); consultarETA(dom.id, token, true);
}; };
const conectarWebSocket = (id, t) => {
const ws = new WebSocket(`ws://10.137.112.65:8000/ws/eta/${id}?token=${t}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.mensaje) {
if (data.evento === 'TRUCK_PROXIMITY' && eta?.evento !== 'TRUCK_PROXIMITY' && notifConfig.proximity) {
enviarNotificacionLocal('🚨 ¡Camión cercano!',
`El camión está a menos de 15 minutos de ${data.colonia}. Saca tus bolsas a la acera.`);
}
if (data.evento === 'ROUTE_START' && eta?.evento !== 'ROUTE_START' && notifConfig.routeStart) {
enviarNotificacionLocal('🟢 Ruta iniciada',
`El camión de ${data.colonia} ha salido. Prepara tus residuos.`);
}
if (data.evento === 'ROUTE_COMPLETED' && eta?.evento !== 'ROUTE_COMPLETED' && notifConfig.completed) {
enviarNotificacionLocal('✅ Servicio finalizado',
`El camión de ${data.colonia} ha concluido su jornada.`);
}
setEta(data);
}
};
ws.onerror = () => console.log('WS error, usando polling');
ws.onclose = () => console.log('WS cerrado');
return ws;
};
if (screen === 'splash') return ( if (screen === 'splash') return (
<View style={styles.splashContainer}> <View style={styles.splashContainer}>
<Text style={styles.splashEmoji}>🚛</Text> <Text style={styles.splashEmoji}>🚛</Text>
@@ -243,7 +259,6 @@ useEffect(() => {
<Text style={styles.bigEmoji}>🚛</Text> <Text style={styles.bigEmoji}>🚛</Text>
<Text style={styles.title}>BasuraApp</Text> <Text style={styles.title}>BasuraApp</Text>
<Text style={styles.subtitle}>Ingresa a tu cuenta</Text> <Text style={styles.subtitle}>Ingresa a tu cuenta</Text>
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<TouchableOpacity style={[styles.toggleBtn, !usarTelefono && styles.toggleActive]} <TouchableOpacity style={[styles.toggleBtn, !usarTelefono && styles.toggleActive]}
onPress={() => setUsarTelefono(false)}> onPress={() => setUsarTelefono(false)}>
@@ -254,7 +269,6 @@ useEffect(() => {
<Text style={[styles.toggleText, usarTelefono && styles.toggleTextActive]}>📱 Teléfono</Text> <Text style={[styles.toggleText, usarTelefono && styles.toggleTextActive]}>📱 Teléfono</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{usarTelefono {usarTelefono
? <TextInput style={styles.input} placeholder="Número de teléfono" value={telefono} ? <TextInput style={styles.input} placeholder="Número de teléfono" value={telefono}
onChangeText={setTelefono} keyboardType="phone-pad" /> onChangeText={setTelefono} keyboardType="phone-pad" />
@@ -263,7 +277,6 @@ useEffect(() => {
} }
<TextInput style={styles.input} placeholder="Contraseña" value={password} <TextInput style={styles.input} placeholder="Contraseña" value={password}
onChangeText={setPassword} secureTextEntry /> onChangeText={setPassword} secureTextEntry />
{loading ? <ActivityIndicator color="#1a7a4a" size="large" style={{ marginTop: 16 }} /> : <> {loading ? <ActivityIndicator color="#1a7a4a" size="large" style={{ marginTop: 16 }} /> : <>
<TouchableOpacity style={styles.btn} onPress={login}> <TouchableOpacity style={styles.btn} onPress={login}>
<Text style={styles.btnText}>Iniciar sesión</Text> <Text style={styles.btnText}>Iniciar sesión</Text>
@@ -287,19 +300,16 @@ useEffect(() => {
<ScrollView contentContainerStyle={styles.container}> <ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>📍 Agregar domicilio</Text> <Text style={styles.title}>📍 Agregar domicilio</Text>
<Text style={styles.subtitle}>¿Dónde quieres recibir alertas?</Text> <Text style={styles.subtitle}>¿Dónde quieres recibir alertas?</Text>
<TextInput style={styles.input} placeholder="Dirección (ej: Calle Morelos 123)" <TextInput style={styles.input} placeholder="Dirección (ej: Calle Morelos 123)"
value={direccion} onChangeText={setDireccion} /> value={direccion} onChangeText={setDireccion} />
<TextInput style={styles.input} placeholder="Código postal (ej: 38000)" <TextInput style={styles.input} placeholder="Código postal (ej: 38000)"
value={codigoPostal} onChangeText={setCodigoPostal} keyboardType="numeric" /> value={codigoPostal} onChangeText={setCodigoPostal} keyboardType="numeric" />
<TouchableOpacity style={[styles.input, styles.combobox]} <TouchableOpacity style={[styles.input, styles.combobox]}
onPress={() => setMostrarColonias(!mostrarColonias)}> onPress={() => setMostrarColonias(!mostrarColonias)}>
<Text style={{ color: coloniaSeleccionada ? '#1a1a1a' : '#999', fontSize: 15 }}> <Text style={{ color: coloniaSeleccionada ? '#1a1a1a' : '#999', fontSize: 15 }}>
{coloniaSeleccionada || 'Selecciona tu colonia ▾'} {coloniaSeleccionada || 'Selecciona tu colonia ▾'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{mostrarColonias && ( {mostrarColonias && (
<View style={styles.dropdown}> <View style={styles.dropdown}>
{COLONIAS.map(c => ( {COLONIAS.map(c => (
@@ -310,7 +320,6 @@ useEffect(() => {
))} ))}
</View> </View>
)} )}
{domicilios.length > 0 && ( {domicilios.length > 0 && (
<View style={styles.domiciliosExistentes}> <View style={styles.domiciliosExistentes}>
<Text style={styles.domiciliosTitle}>Tus domicilios registrados:</Text> <Text style={styles.domiciliosTitle}>Tus domicilios registrados:</Text>
@@ -323,17 +332,14 @@ useEffect(() => {
))} ))}
</View> </View>
)} )}
{loading ? <ActivityIndicator color="#1a7a4a" size="large" /> : {loading ? <ActivityIndicator color="#1a7a4a" size="large" /> :
<TouchableOpacity style={styles.btn} onPress={guardarDomicilio}> <TouchableOpacity style={styles.btn} onPress={guardarDomicilio}>
<Text style={styles.btnText}>Guardar domicilio</Text> <Text style={styles.btnText}>Guardar domicilio</Text>
</TouchableOpacity>} </TouchableOpacity>}
{domicilios.length > 0 && {domicilios.length > 0 &&
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('eta')}> <TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('eta')}>
<Text style={styles.btnSecondaryText}> Volver al horario</Text> <Text style={styles.btnSecondaryText}> Volver al horario</Text>
</TouchableOpacity>} </TouchableOpacity>}
<TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 24 }}> <TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 24 }}>
<Text style={styles.logoutText}>Cerrar sesión</Text> <Text style={styles.logoutText}>Cerrar sesión</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -344,7 +350,6 @@ useEffect(() => {
<ScrollView contentContainerStyle={styles.container} <ScrollView contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1a7a4a']} />}> refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1a7a4a']} />}>
<Text style={styles.title}>🕐 Horario de recolección</Text> <Text style={styles.title}>🕐 Horario de recolección</Text>
{domicilios.length > 1 && ( {domicilios.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} <ScrollView horizontal showsHorizontalScrollIndicator={false}
style={{ width: '100%', marginBottom: 12 }} style={{ width: '100%', marginBottom: 12 }}
@@ -360,7 +365,6 @@ useEffect(() => {
))} ))}
</ScrollView> </ScrollView>
)} )}
{loading ? <ActivityIndicator size="large" color="#1a7a4a" /> : eta ? <> {loading ? <ActivityIndicator size="large" color="#1a7a4a" /> : eta ? <>
<View style={styles.etaCard}> <View style={styles.etaCard}>
<Text style={styles.etaEvento}> <Text style={styles.etaEvento}>
@@ -390,6 +394,9 @@ useEffect(() => {
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('reporte')}> <TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('reporte')}>
<Text style={styles.btnSecondaryText}>📋 Reportar incidencia</Text> <Text style={styles.btnSecondaryText}>📋 Reportar incidencia</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('configuracion')}>
<Text style={styles.btnSecondaryText}> Configurar alertas</Text>
</TouchableOpacity>
<TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 16 }}> <TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 16 }}>
<Text style={styles.logoutText}>Cerrar sesión</Text> <Text style={styles.logoutText}>Cerrar sesión</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -441,6 +448,35 @@ useEffect(() => {
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
); );
if (screen === 'configuracion') return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}> Configurar alertas</Text>
<Text style={styles.subtitle}>Elige qué notificaciones recibir</Text>
{[
{ key: 'routeStart', label: '🟢 Ruta iniciada', desc: 'Cuando el camión sale del depósito' },
{ key: 'proximity', label: '🚨 Camión cercano', desc: 'Cuando el camión está a menos de 15 min' },
{ key: 'completed', label: '✅ Servicio finalizado', desc: 'Cuando el camión termina su recorrido' },
].map(item => (
<TouchableOpacity key={item.key}
style={[styles.notifItem, notifConfig[item.key] && styles.notifItemActivo]}
onPress={async () => {
const nueva = { ...notifConfig, [item.key]: !notifConfig[item.key] };
setNotifConfig(nueva);
await AsyncStorage.setItem('notifConfig', JSON.stringify(nueva));
}}>
<View style={{ flex: 1 }}>
<Text style={styles.notifLabel}>{item.label}</Text>
<Text style={styles.notifDesc}>{item.desc}</Text>
</View>
<Text style={{ fontSize: 24 }}>{notifConfig[item.key] ? '🔔' : '🔕'}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('eta')}>
<Text style={styles.btnSecondaryText}> Volver al horario</Text>
</TouchableOpacity>
</ScrollView>
);
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -489,4 +525,8 @@ const styles = StyleSheet.create({
separacionEmoji: { fontSize: 32 }, separacionEmoji: { fontSize: 32 },
separacionTipo: { fontSize: 16, fontWeight: 'bold', color: '#333' }, separacionTipo: { fontSize: 16, fontWeight: 'bold', color: '#333' },
separacionEjemplos: { fontSize: 12, color: '#666', marginTop: 2 }, separacionEjemplos: { fontSize: 12, color: '#666', marginTop: 2 },
notifItem: { width: '100%', backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 10, flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#ddd' },
notifItemActivo: { borderColor: '#1a7a4a', backgroundColor: '#e8f5ee' },
notifLabel: { fontSize: 15, fontWeight: 'bold', color: '#333' },
notifDesc: { fontSize: 12, color: '#666', marginTop: 2 },
}); });