feat: configuracion de alertas, WebSocket con fallback polling
This commit is contained in:
154
frontend/App.js
154
frontend/App.js
@@ -2,8 +2,6 @@ import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View, TextInput, TouchableOpacity, ActivityIndicator, ScrollView, Alert, RefreshControl } from 'react-native';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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';
|
||||
|
||||
@@ -11,13 +9,6 @@ const COLONIAS = [
|
||||
'Zona Centro', 'Las Arboledas', 'Trojes', 'San Juanico',
|
||||
'Los Olivos', 'Rancho Seco', 'Las Insurgentes'
|
||||
];
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState('splash');
|
||||
@@ -35,25 +26,43 @@ export default function App() {
|
||||
const [mostrarColonias, setMostrarColonias] = useState(false);
|
||||
const [direccion, setDireccion] = useState('');
|
||||
const [codigoPostal, setCodigoPostal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
cargarSesion();
|
||||
registrarNotificaciones();
|
||||
}, []);
|
||||
const [notifConfig, setNotifConfig] = useState({
|
||||
routeStart: true,
|
||||
proximity: true,
|
||||
completed: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
cargarSesion();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let ws = null;
|
||||
let interval = null;
|
||||
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);
|
||||
}, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, 120000);
|
||||
}
|
||||
return () => {
|
||||
if (ws) ws.close();
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [screen, domicilioActivo, token]);
|
||||
|
||||
const enviarNotificacionLocal = async (titulo, cuerpo) => {
|
||||
Alert.alert(titulo, cuerpo);
|
||||
};
|
||||
|
||||
const cargarSesion = async () => {
|
||||
try {
|
||||
const t = await AsyncStorage.getItem('token');
|
||||
const dId = await AsyncStorage.getItem('domicilioId');
|
||||
const config = await AsyncStorage.getItem('notifConfig');
|
||||
if (config) setNotifConfig(JSON.parse(config));
|
||||
if (t) {
|
||||
setToken(t);
|
||||
const doms = await cargarDomicilios(t);
|
||||
@@ -87,6 +96,7 @@ useEffect(() => {
|
||||
setToken(null); setDomicilios([]); setDomicilioActivo(null);
|
||||
setEta(null); setEmail(''); setPassword(''); setTelefono('');
|
||||
setDireccion(''); setColoniaSeleccionada(''); setCodigoPostal('');
|
||||
setNotifConfig({ routeStart: true, proximity: true, completed: true });
|
||||
setScreen('login');
|
||||
};
|
||||
|
||||
@@ -180,23 +190,17 @@ useEffect(() => {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.mensaje) {
|
||||
if (data.evento === 'TRUCK_PROXIMITY' && eta?.evento !== 'TRUCK_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 === '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') {
|
||||
enviarNotificacionLocal(
|
||||
'🟢 Ruta iniciada',
|
||||
`El camión de ${data.colonia} ha salido. Prepara tus residuos.`
|
||||
);
|
||||
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') {
|
||||
enviarNotificacionLocal(
|
||||
'✅ Servicio finalizado',
|
||||
`El camión de ${data.colonia} ha concluido su jornada.`
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -210,25 +214,37 @@ useEffect(() => {
|
||||
setRefreshing(false);
|
||||
}, [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) => {
|
||||
setDomicilioActivo(dom);
|
||||
await AsyncStorage.setItem('domicilioId', String(dom.id));
|
||||
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 (
|
||||
<View style={styles.splashContainer}>
|
||||
<Text style={styles.splashEmoji}>🚛</Text>
|
||||
@@ -243,7 +259,6 @@ useEffect(() => {
|
||||
<Text style={styles.bigEmoji}>🚛</Text>
|
||||
<Text style={styles.title}>BasuraApp</Text>
|
||||
<Text style={styles.subtitle}>Ingresa a tu cuenta</Text>
|
||||
|
||||
<View style={styles.toggleRow}>
|
||||
<TouchableOpacity style={[styles.toggleBtn, !usarTelefono && styles.toggleActive]}
|
||||
onPress={() => setUsarTelefono(false)}>
|
||||
@@ -254,7 +269,6 @@ useEffect(() => {
|
||||
<Text style={[styles.toggleText, usarTelefono && styles.toggleTextActive]}>📱 Teléfono</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{usarTelefono
|
||||
? <TextInput style={styles.input} placeholder="Número de teléfono" value={telefono}
|
||||
onChangeText={setTelefono} keyboardType="phone-pad" />
|
||||
@@ -263,7 +277,6 @@ useEffect(() => {
|
||||
}
|
||||
<TextInput style={styles.input} placeholder="Contraseña" value={password}
|
||||
onChangeText={setPassword} secureTextEntry />
|
||||
|
||||
{loading ? <ActivityIndicator color="#1a7a4a" size="large" style={{ marginTop: 16 }} /> : <>
|
||||
<TouchableOpacity style={styles.btn} onPress={login}>
|
||||
<Text style={styles.btnText}>Iniciar sesión</Text>
|
||||
@@ -287,19 +300,16 @@ useEffect(() => {
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>📍 Agregar domicilio</Text>
|
||||
<Text style={styles.subtitle}>¿Dónde quieres recibir alertas?</Text>
|
||||
|
||||
<TextInput style={styles.input} placeholder="Dirección (ej: Calle Morelos 123)"
|
||||
value={direccion} onChangeText={setDireccion} />
|
||||
<TextInput style={styles.input} placeholder="Código postal (ej: 38000)"
|
||||
value={codigoPostal} onChangeText={setCodigoPostal} keyboardType="numeric" />
|
||||
|
||||
<TouchableOpacity style={[styles.input, styles.combobox]}
|
||||
onPress={() => setMostrarColonias(!mostrarColonias)}>
|
||||
<Text style={{ color: coloniaSeleccionada ? '#1a1a1a' : '#999', fontSize: 15 }}>
|
||||
{coloniaSeleccionada || 'Selecciona tu colonia ▾'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{mostrarColonias && (
|
||||
<View style={styles.dropdown}>
|
||||
{COLONIAS.map(c => (
|
||||
@@ -310,7 +320,6 @@ useEffect(() => {
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{domicilios.length > 0 && (
|
||||
<View style={styles.domiciliosExistentes}>
|
||||
<Text style={styles.domiciliosTitle}>Tus domicilios registrados:</Text>
|
||||
@@ -323,17 +332,14 @@ useEffect(() => {
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading ? <ActivityIndicator color="#1a7a4a" size="large" /> :
|
||||
<TouchableOpacity style={styles.btn} onPress={guardarDomicilio}>
|
||||
<Text style={styles.btnText}>Guardar domicilio</Text>
|
||||
</TouchableOpacity>}
|
||||
|
||||
{domicilios.length > 0 &&
|
||||
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('eta')}>
|
||||
<Text style={styles.btnSecondaryText}>← Volver al horario</Text>
|
||||
</TouchableOpacity>}
|
||||
|
||||
<TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 24 }}>
|
||||
<Text style={styles.logoutText}>Cerrar sesión</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -344,7 +350,6 @@ useEffect(() => {
|
||||
<ScrollView contentContainerStyle={styles.container}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1a7a4a']} />}>
|
||||
<Text style={styles.title}>🕐 Horario de recolección</Text>
|
||||
|
||||
{domicilios.length > 1 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
@@ -360,7 +365,6 @@ useEffect(() => {
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{loading ? <ActivityIndicator size="large" color="#1a7a4a" /> : eta ? <>
|
||||
<View style={styles.etaCard}>
|
||||
<Text style={styles.etaEvento}>
|
||||
@@ -390,6 +394,9 @@ useEffect(() => {
|
||||
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('reporte')}>
|
||||
<Text style={styles.btnSecondaryText}>📋 Reportar incidencia</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.btnSecondary} onPress={() => setScreen('configuracion')}>
|
||||
<Text style={styles.btnSecondaryText}>⚙️ Configurar alertas</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={cerrarSesion} style={{ marginTop: 16 }}>
|
||||
<Text style={styles.logoutText}>Cerrar sesión</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -441,6 +448,35 @@ useEffect(() => {
|
||||
</TouchableOpacity>
|
||||
</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({
|
||||
@@ -489,4 +525,8 @@ const styles = StyleSheet.create({
|
||||
separacionEmoji: { fontSize: 32 },
|
||||
separacionTipo: { fontSize: 16, fontWeight: 'bold', color: '#333' },
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user