FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

50
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native builds
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# Debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Typescript
*.tsbuildinfo
# Testing
coverage/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Temporary files
tmp/
temp/

6
mobile/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
/**
* App.tsx - Entry point for Expo
* Re-exports the main App component from src
*/
export { default } from './src/App';

86
mobile/app.json Normal file
View File

@@ -0,0 +1,86 @@
{
"expo": {
"name": "Adan Conductor",
"slug": "adan-conductor",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#3b82f6"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.adan.conductor",
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "Necesitamos acceso a tu ubicación para rastrear el viaje",
"NSLocationAlwaysAndWhenInUseUsageDescription": "Necesitamos acceso continuo a tu ubicación para el seguimiento de rutas",
"NSLocationAlwaysUsageDescription": "Necesitamos acceso a tu ubicación en segundo plano",
"NSCameraUsageDescription": "Necesitamos acceso a la cámara para la función de dashcam",
"NSMicrophoneUsageDescription": "Necesitamos acceso al micrófono para grabar audio",
"UIBackgroundModes": [
"location",
"fetch",
"remote-notification"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#3b82f6"
},
"package": "com.adan.conductor",
"permissions": [
"ACCESS_COARSE_LOCATION",
"ACCESS_FINE_LOCATION",
"ACCESS_BACKGROUND_LOCATION",
"CAMERA",
"RECORD_AUDIO",
"RECEIVE_BOOT_COMPLETED",
"VIBRATE",
"WAKE_LOCK",
"FOREGROUND_SERVICE"
],
"config": {
"googleMaps": {
"apiKey": "YOUR_GOOGLE_MAPS_API_KEY"
}
}
},
"plugins": [
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Adan necesita tu ubicación para rastrear viajes, incluso en segundo plano.",
"isAndroidBackgroundLocationEnabled": true,
"isIosBackgroundLocationEnabled": true
}
],
[
"expo-camera",
{
"cameraPermission": "Permitir a Adan acceder a tu cámara para la función dashcam"
}
],
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#3b82f6",
"sounds": ["./assets/notification-sound.wav"]
}
]
],
"extra": {
"eas": {
"projectId": "your-project-id"
}
}
}
}

26
mobile/babel.config.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['./src'],
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
alias: {
'@': './src',
'@components': './src/components',
'@screens': './src/screens',
'@services': './src/services',
'@hooks': './src/hooks',
'@store': './src/store',
'@utils': './src/utils',
'@types': './src/types',
},
},
],
'react-native-reanimated/plugin',
],
};
};

43
mobile/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "adan-driver-app",
"version": "1.0.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint . --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"@react-navigation/stack": "^6.3.20",
"axios": "^1.6.2",
"expo": "~50.0.0",
"expo-camera": "~14.0.0",
"expo-location": "~16.5.0",
"expo-notifications": "~0.27.0",
"expo-status-bar": "~1.11.1",
"expo-task-manager": "~11.7.0",
"react": "18.2.0",
"react-native": "0.73.2",
"react-native-gesture-handler": "~2.14.0",
"react-native-maps": "1.10.0",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.2.45",
"@types/react-native": "~0.73.0",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"eslint": "^8.55.0",
"typescript": "^5.3.0"
}
}

362
mobile/src/App.tsx Normal file
View File

@@ -0,0 +1,362 @@
/**
* App.tsx - Punto de entrada principal y configuración de navegación
* Adan Conductor - App móvil para conductores
*/
import React, { useEffect, useState } from 'react';
import { StatusBar, View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { SafeAreaProvider } from 'react-native-safe-area-context';
// Screens
import {
LoginScreen,
VerifyCodeScreen,
HomeScreen,
ViajeActivoScreen,
RegistrarParadaScreen,
CombustibleScreen,
MensajesScreen,
EmergenciaScreen,
PerfilScreen,
CamaraScreen,
} from './screens';
// Stores & Hooks
import { useAuthStore, initializeConnectionMonitoring } from './store';
import { initializeNotificationListeners, removeNotificationListeners } from './services/notifications';
// Theme
import { COLORS, FONT_SIZE, FONT_WEIGHT, SPACING } from './utils/theme';
// Types
import type {
RootStackParamList,
AuthStackParamList,
MainTabParamList,
ViajeStackParamList,
} from './types';
// Navigators
const RootStack = createStackNavigator<RootStackParamList>();
const AuthStack = createStackNavigator<AuthStackParamList>();
const MainTab = createBottomTabNavigator<MainTabParamList>();
const ViajeStack = createStackNavigator<ViajeStackParamList>();
// ==========================================
// TAB BAR ICON COMPONENT
// ==========================================
interface TabIconProps {
focused: boolean;
emoji: string;
label: string;
}
const TabIcon: React.FC<TabIconProps> = ({ focused, emoji, label }) => (
<View style={styles.tabIconContainer}>
<Text style={[styles.tabEmoji, focused && styles.tabEmojiActive]}>
{emoji}
</Text>
<Text style={[styles.tabLabel, focused && styles.tabLabelActive]}>
{label}
</Text>
</View>
);
// ==========================================
// AUTH NAVIGATOR
// ==========================================
const AuthNavigator: React.FC = () => {
return (
<AuthStack.Navigator
screenOptions={{
headerShown: false,
cardStyle: { backgroundColor: COLORS.background },
}}
>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="VerifyCode" component={VerifyCodeScreen} />
</AuthStack.Navigator>
);
};
// ==========================================
// VIAJE STACK NAVIGATOR
// ==========================================
const ViajeNavigator: React.FC = () => {
return (
<ViajeStack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: COLORS.white,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
headerTitleStyle: {
fontWeight: FONT_WEIGHT.semibold,
fontSize: FONT_SIZE.lg,
color: COLORS.textPrimary,
},
headerTintColor: COLORS.primary,
headerBackTitleVisible: false,
}}
>
<ViajeStack.Screen
name="ViajeActivo"
component={ViajeActivoScreen}
options={{ title: 'Viaje en Curso' }}
/>
<ViajeStack.Screen
name="RegistrarParada"
component={RegistrarParadaScreen}
options={{ title: 'Registrar Parada' }}
/>
<ViajeStack.Screen
name="Combustible"
component={CombustibleScreen}
options={{ title: 'Cargar Combustible' }}
/>
<ViajeStack.Screen
name="Emergencia"
component={EmergenciaScreen}
options={{
title: 'Emergencia',
headerStyle: {
backgroundColor: COLORS.danger,
},
headerTitleStyle: {
color: COLORS.white,
fontWeight: FONT_WEIGHT.bold,
},
headerTintColor: COLORS.white,
}}
/>
<ViajeStack.Screen
name="Camara"
component={CamaraScreen}
options={{
title: 'Dashcam',
headerTransparent: true,
headerTintColor: COLORS.white,
}}
/>
</ViajeStack.Navigator>
);
};
// ==========================================
// MAIN TAB NAVIGATOR
// ==========================================
const MainNavigator: React.FC = () => {
return (
<MainTab.Navigator
screenOptions={{
headerStyle: {
backgroundColor: COLORS.white,
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
headerTitleStyle: {
fontWeight: FONT_WEIGHT.semibold,
fontSize: FONT_SIZE.lg,
color: COLORS.textPrimary,
},
tabBarStyle: {
backgroundColor: COLORS.white,
borderTopWidth: 1,
borderTopColor: COLORS.border,
height: 70,
paddingBottom: SPACING.sm,
paddingTop: SPACING.xs,
},
tabBarShowLabel: false,
tabBarActiveTintColor: COLORS.primary,
tabBarInactiveTintColor: COLORS.textTertiary,
}}
>
<MainTab.Screen
name="Home"
component={HomeScreen}
options={{
title: 'Inicio',
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} emoji="🏠" label="Inicio" />
),
}}
/>
<MainTab.Screen
name="Viaje"
component={ViajeNavigator}
options={{
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} emoji="🚛" label="Viaje" />
),
}}
/>
<MainTab.Screen
name="Mensajes"
component={MensajesScreen}
options={{
title: 'Mensajes',
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} emoji="💬" label="Mensajes" />
),
}}
/>
<MainTab.Screen
name="Perfil"
component={PerfilScreen}
options={{
title: 'Mi Perfil',
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} emoji="👤" label="Perfil" />
),
}}
/>
</MainTab.Navigator>
);
};
// ==========================================
// ROOT NAVIGATOR
// ==========================================
const RootNavigator: React.FC = () => {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={COLORS.primary} />
<Text style={styles.loadingText}>Cargando...</Text>
</View>
);
}
return (
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<RootStack.Screen name="Main" component={MainNavigator} />
) : (
<RootStack.Screen name="Auth" component={AuthNavigator} />
)}
</RootStack.Navigator>
);
};
// ==========================================
// APP COMPONENT
// ==========================================
const App: React.FC = () => {
const [isReady, setIsReady] = useState(false);
const initialize = useAuthStore((state) => state.initialize);
useEffect(() => {
const setup = async () => {
try {
// Inicializar autenticación
await initialize();
// Inicializar monitoreo de conexión
const cleanupConnection = initializeConnectionMonitoring();
// Inicializar notificaciones
initializeNotificationListeners({
onReceived: (notification) => {
console.log('Notificación recibida:', notification);
},
onResponse: (response) => {
console.log('Respuesta a notificación:', response);
// Aquí se puede manejar la navegación según el tipo de notificación
},
});
setIsReady(true);
// Cleanup
return () => {
cleanupConnection();
removeNotificationListeners();
};
} catch (error) {
console.error('Error inicializando app:', error);
setIsReady(true);
}
};
setup();
}, [initialize]);
if (!isReady) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
);
}
return (
<SafeAreaProvider>
<StatusBar
barStyle="dark-content"
backgroundColor={COLORS.white}
translucent={false}
/>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
);
};
// ==========================================
// STYLES
// ==========================================
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: COLORS.background,
},
loadingText: {
marginTop: SPACING.md,
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
tabIconContainer: {
alignItems: 'center',
justifyContent: 'center',
},
tabEmoji: {
fontSize: 24,
marginBottom: 2,
opacity: 0.6,
},
tabEmojiActive: {
opacity: 1,
},
tabLabel: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
tabLabelActive: {
color: COLORS.primary,
fontWeight: FONT_WEIGHT.medium,
},
});
export default App;

View File

@@ -0,0 +1,156 @@
/**
* Componente Button - Botón reutilizable
* Diseñado con tamaños grandes para uso mientras conduce
*/
import React from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
} from 'react-native';
import { COLORS, BORDER_RADIUS, BUTTON_SIZES, SHADOWS, FONT_WEIGHT } from '../utils/theme';
type ButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'outline' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
style?: ViewStyle;
textStyle?: TextStyle;
}
export const Button: React.FC<ButtonProps> = ({
title,
onPress,
variant = 'primary',
size = 'lg',
disabled = false,
loading = false,
fullWidth = false,
icon,
iconPosition = 'left',
style,
textStyle,
}) => {
const isDisabled = disabled || loading;
const getBackgroundColor = (): string => {
if (isDisabled) return COLORS.gray300;
switch (variant) {
case 'primary':
return COLORS.primary;
case 'secondary':
return COLORS.gray600;
case 'success':
return COLORS.success;
case 'danger':
return COLORS.danger;
case 'outline':
case 'ghost':
return 'transparent';
default:
return COLORS.primary;
}
};
const getTextColor = (): string => {
if (isDisabled && (variant === 'outline' || variant === 'ghost')) {
return COLORS.gray400;
}
switch (variant) {
case 'outline':
return COLORS.primary;
case 'ghost':
return COLORS.textPrimary;
default:
return COLORS.white;
}
};
const getBorderColor = (): string => {
if (isDisabled) return COLORS.gray300;
if (variant === 'outline') {
return COLORS.primary;
}
return 'transparent';
};
const sizeStyles = BUTTON_SIZES[size];
return (
<TouchableOpacity
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.8}
style={[
styles.button,
{
backgroundColor: getBackgroundColor(),
borderColor: getBorderColor(),
height: sizeStyles.height,
paddingHorizontal: sizeStyles.paddingHorizontal,
},
variant !== 'ghost' && SHADOWS.sm,
fullWidth && styles.fullWidth,
style,
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} size="small" />
) : (
<>
{icon && iconPosition === 'left' && icon}
<Text
style={[
styles.text,
{
color: getTextColor(),
fontSize: sizeStyles.fontSize,
marginLeft: icon && iconPosition === 'left' ? 8 : 0,
marginRight: icon && iconPosition === 'right' ? 8 : 0,
},
textStyle,
]}
>
{title}
</Text>
{icon && iconPosition === 'right' && icon}
</>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: BORDER_RADIUS.lg,
borderWidth: 2,
},
text: {
fontWeight: FONT_WEIGHT.semibold,
},
fullWidth: {
width: '100%',
},
});
export default Button;

View File

@@ -0,0 +1,155 @@
/**
* Componente Input - Campo de entrada reutilizable
*/
import React, { useState } from 'react';
import {
View,
TextInput,
Text,
StyleSheet,
TextInputProps,
ViewStyle,
TouchableOpacity,
} from 'react-native';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
FONT_WEIGHT,
} from '../utils/theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
hint?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onRightIconPress?: () => void;
containerStyle?: ViewStyle;
inputContainerStyle?: ViewStyle;
size?: 'md' | 'lg';
}
export const Input: React.FC<InputProps> = ({
label,
error,
hint,
leftIcon,
rightIcon,
onRightIconPress,
containerStyle,
inputContainerStyle,
size = 'lg',
style,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const getBorderColor = (): string => {
if (error) return COLORS.danger;
if (isFocused) return COLORS.primary;
return COLORS.border;
};
const inputHeight = size === 'lg' ? 56 : 48;
const fontSize = size === 'lg' ? FONT_SIZE.lg : FONT_SIZE.md;
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
<View
style={[
styles.inputContainer,
{
borderColor: getBorderColor(),
height: inputHeight,
},
isFocused && styles.inputContainerFocused,
inputContainerStyle,
]}
>
{leftIcon && <View style={styles.leftIcon}>{leftIcon}</View>}
<TextInput
style={[
styles.input,
{
fontSize,
paddingLeft: leftIcon ? 0 : SPACING.md,
paddingRight: rightIcon ? 0 : SPACING.md,
},
style,
]}
placeholderTextColor={COLORS.textTertiary}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
/>
{rightIcon && (
<TouchableOpacity
onPress={onRightIconPress}
disabled={!onRightIconPress}
style={styles.rightIcon}
>
{rightIcon}
</TouchableOpacity>
)}
</View>
{(error || hint) && (
<Text style={[styles.helperText, error && styles.errorText]}>
{error || hint}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: SPACING.md,
},
label: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textSecondary,
marginBottom: SPACING.xs,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderWidth: 2,
borderRadius: BORDER_RADIUS.lg,
},
inputContainerFocused: {
borderWidth: 2,
},
input: {
flex: 1,
color: COLORS.textPrimary,
height: '100%',
},
leftIcon: {
paddingLeft: SPACING.md,
paddingRight: SPACING.sm,
},
rightIcon: {
paddingRight: SPACING.md,
paddingLeft: SPACING.sm,
},
helperText: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
marginTop: SPACING.xs,
},
errorText: {
color: COLORS.danger,
},
});
export default Input;

View File

@@ -0,0 +1,146 @@
/**
* Componente LoadingOverlay - Overlay de carga con spinner
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
Modal,
ViewStyle,
} from 'react-native';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
FONT_WEIGHT,
} from '../utils/theme';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
transparent?: boolean;
style?: ViewStyle;
}
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
visible,
message = 'Cargando...',
transparent = true,
style,
}) => {
if (!visible) return null;
return (
<Modal
visible={visible}
transparent={transparent}
animationType="fade"
statusBarTranslucent
>
<View style={[styles.overlay, style]}>
<View style={styles.container}>
<ActivityIndicator size="large" color={COLORS.primary} />
{message && <Text style={styles.message}>{message}</Text>}
</View>
</View>
</Modal>
);
};
/**
* Componente LoadingSpinner - Spinner inline
*/
interface LoadingSpinnerProps {
size?: 'small' | 'large';
color?: string;
message?: string;
style?: ViewStyle;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'large',
color = COLORS.primary,
message,
style,
}) => {
return (
<View style={[styles.spinnerContainer, style]}>
<ActivityIndicator size={size} color={color} />
{message && <Text style={styles.spinnerMessage}>{message}</Text>}
</View>
);
};
/**
* Componente SkeletonLoader - Placeholder de carga
*/
interface SkeletonLoaderProps {
width?: number | string;
height?: number;
borderRadius?: number;
style?: ViewStyle;
}
export const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
width = '100%',
height = 20,
borderRadius = BORDER_RADIUS.md,
style,
}) => {
return (
<View
style={[
styles.skeleton,
{
width,
height,
borderRadius,
},
style,
]}
/>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: COLORS.overlay,
alignItems: 'center',
justifyContent: 'center',
},
container: {
backgroundColor: COLORS.white,
padding: SPACING.xl,
borderRadius: BORDER_RADIUS.xl,
alignItems: 'center',
minWidth: 150,
},
message: {
marginTop: SPACING.md,
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
textAlign: 'center',
},
spinnerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: SPACING.xl,
},
spinnerMessage: {
marginTop: SPACING.md,
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
skeleton: {
backgroundColor: COLORS.gray200,
overflow: 'hidden',
},
});
export default LoadingOverlay;

View File

@@ -0,0 +1,281 @@
/**
* Componente MapView - Mapa con ubicación actual y ruta
*/
import React, { useRef, useEffect } from 'react';
import {
View,
StyleSheet,
ViewStyle,
Dimensions,
Platform,
} from 'react-native';
import RNMapView, {
Marker,
Polyline,
PROVIDER_GOOGLE,
Region,
MapViewProps as RNMapViewProps,
} from 'react-native-maps';
import { COLORS, BORDER_RADIUS } from '../utils/theme';
import type { Ubicacion, Punto, Parada } from '../types';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface MapViewProps {
ubicacionActual?: Ubicacion | null;
origen?: Punto;
destino?: Punto;
paradas?: Parada[];
ruta?: Array<{ latitude: number; longitude: number }>;
showUserLocation?: boolean;
followUser?: boolean;
onMapReady?: () => void;
onRegionChange?: (region: Region) => void;
style?: ViewStyle;
height?: number;
zoomLevel?: number;
}
export const MapView: React.FC<MapViewProps> = ({
ubicacionActual,
origen,
destino,
paradas = [],
ruta = [],
showUserLocation = true,
followUser = false,
onMapReady,
onRegionChange,
style,
height = 300,
zoomLevel = 15,
}) => {
const mapRef = useRef<RNMapView>(null);
// Centrar mapa en ubicación actual cuando cambia
useEffect(() => {
if (followUser && ubicacionActual && mapRef.current) {
mapRef.current.animateToRegion(
{
latitude: ubicacionActual.latitud,
longitude: ubicacionActual.longitud,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
},
500
);
}
}, [followUser, ubicacionActual]);
// Ajustar mapa para mostrar toda la ruta
const fitToRoute = () => {
if (!mapRef.current) return;
const coordinates: Array<{ latitude: number; longitude: number }> = [];
if (ubicacionActual) {
coordinates.push({
latitude: ubicacionActual.latitud,
longitude: ubicacionActual.longitud,
});
}
if (origen) {
coordinates.push({
latitude: origen.latitud,
longitude: origen.longitud,
});
}
if (destino) {
coordinates.push({
latitude: destino.latitud,
longitude: destino.longitud,
});
}
paradas.forEach((parada) => {
coordinates.push({
latitude: parada.punto.latitud,
longitude: parada.punto.longitud,
});
});
if (coordinates.length > 1) {
mapRef.current.fitToCoordinates(coordinates, {
edgePadding: { top: 50, right: 50, bottom: 50, left: 50 },
animated: true,
});
}
};
// Región inicial
const initialRegion: Region = {
latitude: ubicacionActual?.latitud || 19.4326, // CDMX por defecto
longitude: ubicacionActual?.longitud || -99.1332,
latitudeDelta: 0.0922 / zoomLevel,
longitudeDelta: 0.0421 / zoomLevel,
};
const getParadaColor = (parada: Parada): string => {
if (parada.completada) return COLORS.success;
switch (parada.tipo) {
case 'carga':
return COLORS.primary;
case 'descarga':
return COLORS.info;
case 'combustible':
return COLORS.warning;
case 'descanso':
return COLORS.gray500;
default:
return COLORS.gray400;
}
};
return (
<View style={[styles.container, { height }, style]}>
<RNMapView
ref={mapRef}
style={styles.map}
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : undefined}
initialRegion={initialRegion}
showsUserLocation={showUserLocation}
showsMyLocationButton={false}
showsCompass={true}
showsScale={true}
rotateEnabled={true}
pitchEnabled={false}
onMapReady={() => {
onMapReady?.();
if (origen || destino) {
setTimeout(fitToRoute, 500);
}
}}
onRegionChangeComplete={onRegionChange}
>
{/* Marcador de ubicación actual personalizado */}
{ubicacionActual && !showUserLocation && (
<Marker
coordinate={{
latitude: ubicacionActual.latitud,
longitude: ubicacionActual.longitud,
}}
anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.currentLocationMarker}>
<View style={styles.currentLocationDot} />
</View>
</Marker>
)}
{/* Marcador de origen */}
{origen && (
<Marker
coordinate={{
latitude: origen.latitud,
longitude: origen.longitud,
}}
title="Origen"
description={origen.nombre}
pinColor={COLORS.primary}
/>
)}
{/* Marcador de destino */}
{destino && (
<Marker
coordinate={{
latitude: destino.latitud,
longitude: destino.longitud,
}}
title="Destino"
description={destino.nombre}
pinColor={COLORS.danger}
/>
)}
{/* Marcadores de paradas */}
{paradas.map((parada, index) => (
<Marker
key={parada.id}
coordinate={{
latitude: parada.punto.latitud,
longitude: parada.punto.longitud,
}}
title={`Parada ${index + 1}`}
description={parada.punto.nombre}
>
<View
style={[
styles.paradaMarker,
{
backgroundColor: getParadaColor(parada),
opacity: parada.completada ? 0.6 : 1,
},
]}
>
<View style={styles.paradaMarkerInner} />
</View>
</Marker>
))}
{/* Línea de ruta */}
{ruta.length > 1 && (
<Polyline
coordinates={ruta}
strokeColor={COLORS.primary}
strokeWidth={4}
lineDashPattern={[0]}
/>
)}
</RNMapView>
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
borderRadius: BORDER_RADIUS.xl,
overflow: 'hidden',
},
map: {
flex: 1,
},
currentLocationMarker: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: `${COLORS.primary}30`,
alignItems: 'center',
justifyContent: 'center',
},
currentLocationDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: COLORS.primary,
borderWidth: 2,
borderColor: COLORS.white,
},
paradaMarker: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 3,
borderColor: COLORS.white,
},
paradaMarkerInner: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: COLORS.white,
},
});
export default MapView;

View File

@@ -0,0 +1,169 @@
/**
* Componente StatCard - Card para mostrar estadísticas
*/
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon?: React.ReactNode;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
style?: ViewStyle;
size?: 'sm' | 'md' | 'lg';
}
export const StatCard: React.FC<StatCardProps> = ({
title,
value,
subtitle,
icon,
color = COLORS.primary,
trend,
style,
size = 'md',
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
return {
padding: SPACING.sm,
titleSize: FONT_SIZE.xs,
valueSize: FONT_SIZE.lg,
subtitleSize: FONT_SIZE.xs,
};
case 'lg':
return {
padding: SPACING.lg,
titleSize: FONT_SIZE.md,
valueSize: FONT_SIZE.xxxl,
subtitleSize: FONT_SIZE.md,
};
default:
return {
padding: SPACING.md,
titleSize: FONT_SIZE.sm,
valueSize: FONT_SIZE.xxl,
subtitleSize: FONT_SIZE.sm,
};
}
};
const sizeStyles = getSizeStyles();
return (
<View style={[styles.card, { padding: sizeStyles.padding }, SHADOWS.md, style]}>
<View style={styles.header}>
{icon && (
<View
style={[
styles.iconContainer,
{ backgroundColor: `${color}15` },
]}
>
{icon}
</View>
)}
{trend && (
<View
style={[
styles.trendBadge,
{
backgroundColor: trend.isPositive
? `${COLORS.success}15`
: `${COLORS.danger}15`,
},
]}
>
<Text
style={[
styles.trendText,
{
color: trend.isPositive ? COLORS.success : COLORS.danger,
},
]}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</Text>
</View>
)}
</View>
<Text style={[styles.title, { fontSize: sizeStyles.titleSize }]}>
{title}
</Text>
<Text
style={[
styles.value,
{ fontSize: sizeStyles.valueSize, color },
]}
>
{value}
</Text>
{subtitle && (
<Text style={[styles.subtitle, { fontSize: sizeStyles.subtitleSize }]}>
{subtitle}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.sm,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: BORDER_RADIUS.md,
alignItems: 'center',
justifyContent: 'center',
},
trendBadge: {
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
trendText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.semibold,
},
title: {
color: COLORS.textSecondary,
marginBottom: SPACING.xs,
},
value: {
fontWeight: FONT_WEIGHT.bold,
marginBottom: SPACING.xs,
},
subtitle: {
color: COLORS.textTertiary,
},
});
export default StatCard;

View File

@@ -0,0 +1,281 @@
/**
* Componente ViajeCard - Card para mostrar información de viaje
*/
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle } from 'react-native';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import { formatDistance, formatDuration, getEstadoViajeInfo } from '../utils/helpers';
import type { Viaje } from '../types';
interface ViajeCardProps {
viaje: Viaje;
onPress?: () => void;
showDetails?: boolean;
style?: ViewStyle;
}
export const ViajeCard: React.FC<ViajeCardProps> = ({
viaje,
onPress,
showDetails = true,
style,
}) => {
const estadoInfo = getEstadoViajeInfo(viaje.estado);
const paradasPendientes = viaje.paradas.filter((p) => !p.completada).length;
const paradasTotal = viaje.paradas.length;
const Content = (
<View style={[styles.card, SHADOWS.md, style]}>
{/* Header con estado */}
<View style={styles.header}>
<View style={styles.routeInfo}>
<View style={styles.routePoints}>
<View style={styles.originPoint} />
<View style={styles.routeLine} />
<View
style={[styles.destinationPoint, { backgroundColor: estadoInfo.color }]}
/>
</View>
<View style={styles.routeText}>
<Text style={styles.pointLabel}>Origen</Text>
<Text style={styles.pointName} numberOfLines={1}>
{viaje.origen.nombre}
</Text>
<View style={styles.spacer} />
<Text style={styles.pointLabel}>Destino</Text>
<Text style={styles.pointName} numberOfLines={1}>
{viaje.destino.nombre}
</Text>
</View>
</View>
<View
style={[
styles.estadoBadge,
{ backgroundColor: `${estadoInfo.color}15` },
]}
>
<Text style={[styles.estadoText, { color: estadoInfo.color }]}>
{estadoInfo.label}
</Text>
</View>
</View>
{/* Información adicional */}
{showDetails && (
<View style={styles.details}>
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>Distancia</Text>
<Text style={styles.detailValue}>
{formatDistance(viaje.distanciaEstimada)}
</Text>
</View>
<View style={styles.detailDivider} />
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>Tiempo est.</Text>
<Text style={styles.detailValue}>
{formatDuration(viaje.tiempoEstimado)}
</Text>
</View>
<View style={styles.detailDivider} />
<View style={styles.detailItem}>
<Text style={styles.detailLabel}>Paradas</Text>
<Text style={styles.detailValue}>
{paradasPendientes}/{paradasTotal}
</Text>
</View>
</View>
)}
{/* Cliente y carga */}
{(viaje.cliente || viaje.carga) && (
<View style={styles.footer}>
{viaje.cliente && (
<Text style={styles.clienteText} numberOfLines={1}>
Cliente: {viaje.cliente}
</Text>
)}
{viaje.carga && (
<Text style={styles.cargaText} numberOfLines={1}>
{viaje.carga.descripcion}
</Text>
)}
</View>
)}
{/* Progreso si está en curso */}
{viaje.estado === 'en_curso' && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{
width: `${Math.min(
100,
((viaje.distanciaRecorrida || 0) / viaje.distanciaEstimada) * 100
)}%`,
},
]}
/>
</View>
<Text style={styles.progressText}>
{formatDistance(viaje.distanciaRecorrida || 0)} de{' '}
{formatDistance(viaje.distanciaEstimada)}
</Text>
</View>
)}
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
{Content}
</TouchableOpacity>
);
}
return Content;
};
const styles = StyleSheet.create({
card: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
},
routeInfo: {
flexDirection: 'row',
flex: 1,
},
routePoints: {
alignItems: 'center',
marginRight: SPACING.md,
paddingVertical: SPACING.xs,
},
originPoint: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: COLORS.primary,
},
routeLine: {
width: 2,
flex: 1,
backgroundColor: COLORS.gray300,
marginVertical: 4,
},
destinationPoint: {
width: 12,
height: 12,
borderRadius: 6,
},
routeText: {
flex: 1,
},
pointLabel: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
pointName: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textPrimary,
},
spacer: {
height: SPACING.sm,
},
estadoBadge: {
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
alignSelf: 'flex-start',
},
estadoText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.semibold,
},
details: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingTop: SPACING.md,
marginTop: SPACING.md,
borderTopWidth: 1,
borderTopColor: COLORS.border,
},
detailItem: {
alignItems: 'center',
flex: 1,
},
detailLabel: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
marginBottom: 2,
},
detailValue: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
},
detailDivider: {
width: 1,
backgroundColor: COLORS.border,
},
footer: {
marginTop: SPACING.md,
paddingTop: SPACING.sm,
borderTopWidth: 1,
borderTopColor: COLORS.border,
},
clienteText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
},
cargaText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
marginTop: 2,
},
progressContainer: {
marginTop: SPACING.md,
},
progressBar: {
height: 6,
backgroundColor: COLORS.gray200,
borderRadius: BORDER_RADIUS.full,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: COLORS.primary,
borderRadius: BORDER_RADIUS.full,
},
progressText: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
textAlign: 'center',
marginTop: SPACING.xs,
},
});
export default ViajeCard;

View File

@@ -0,0 +1,15 @@
/**
* Exportación centralizada de componentes
*/
export { Button, default as ButtonComponent } from './Button';
export { Input, default as InputComponent } from './Input';
export { StatCard, default as StatCardComponent } from './StatCard';
export { ViajeCard, default as ViajeCardComponent } from './ViajeCard';
export { MapView, default as MapViewComponent } from './MapView';
export {
LoadingOverlay,
LoadingSpinner,
SkeletonLoader,
default as LoadingOverlayComponent,
} from './LoadingOverlay';

View File

@@ -0,0 +1,7 @@
/**
* Exportación centralizada de hooks
*/
export { useAuth, default as useAuthHook } from './useAuth';
export { useLocation, default as useLocationHook } from './useLocation';
export { useViaje, default as useViajeHook } from './useViaje';

View File

@@ -0,0 +1,82 @@
/**
* Hook personalizado para autenticación
* Proporciona acceso al estado de auth y acciones comunes
*/
import { useCallback, useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
export const useAuth = () => {
const {
conductor,
token,
dispositivo,
isAuthenticated,
isLoading,
initialize,
requestCode,
verifyCode,
logout,
refreshToken,
updateConductor,
} = useAuthStore();
// Inicializar al montar
useEffect(() => {
initialize();
}, [initialize]);
// Login con teléfono
const login = useCallback(
async (telefono: string) => {
const result = await requestCode(telefono);
return result;
},
[requestCode]
);
// Verificar código
const verify = useCallback(
async (telefono: string, codigo: string) => {
const result = await verifyCode(telefono, codigo);
return result;
},
[verifyCode]
);
// Cerrar sesión
const signOut = useCallback(async () => {
await logout();
}, [logout]);
// Refrescar token
const refresh = useCallback(async () => {
return await refreshToken();
}, [refreshToken]);
// Actualizar perfil
const updateProfile = useCallback(
(updates: Parameters<typeof updateConductor>[0]) => {
updateConductor(updates);
},
[updateConductor]
);
return {
// Estado
conductor,
token,
dispositivo,
isAuthenticated,
isLoading,
// Acciones
login,
verify,
signOut,
refresh,
updateProfile,
};
};
export default useAuth;

View File

@@ -0,0 +1,243 @@
/**
* Hook personalizado para tracking de ubicación
* Proporciona acceso a ubicación actual y control del tracking
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import {
requestLocationPermissions,
checkLocationPermissions,
getCurrentLocation,
startLocationTracking,
stopLocationTracking,
isTrackingActive,
syncOfflineLocations,
getPendingLocationsCount,
calculateDistance,
} from '../services/location';
import { useUbicacionStore } from '../store/ubicacionStore';
import type { Ubicacion, PermisoStatus } from '../types';
interface UseLocationOptions {
autoStart?: boolean;
viajeId?: string;
updateInterval?: number; // ms
}
interface UseLocationReturn {
// Estado
ubicacion: Ubicacion | null;
isTracking: boolean;
isLoading: boolean;
permissionStatus: PermisoStatus['ubicacion'];
backgroundPermission: PermisoStatus['ubicacionBackground'];
pendingCount: number;
isOnline: boolean;
error: string | null;
// Acciones
requestPermissions: () => Promise<boolean>;
startTracking: (viajeId?: string) => Promise<boolean>;
stopTracking: () => Promise<void>;
refreshLocation: () => Promise<Ubicacion | null>;
syncPending: () => Promise<number>;
getDistanceTo: (lat: number, lon: number) => number | null;
}
export const useLocation = (options: UseLocationOptions = {}): UseLocationReturn => {
const { autoStart = false, viajeId, updateInterval = 5000 } = options;
const [isLoading, setIsLoading] = useState(false);
const [isTracking, setIsTracking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissionStatus, setPermissionStatus] =
useState<PermisoStatus['ubicacion']>('undetermined');
const [backgroundPermission, setBackgroundPermission] =
useState<PermisoStatus['ubicacionBackground']>('undetermined');
const {
ubicacionActual,
isOnline,
pendingCount,
setUbicacionActual,
syncPendingLocations,
refreshPendingCount,
} = useUbicacionStore();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Verificar permisos al montar
useEffect(() => {
const checkPermissions = async () => {
const permissions = await checkLocationPermissions();
setPermissionStatus(permissions.foreground ? 'granted' : 'denied');
setBackgroundPermission(permissions.background ? 'granted' : 'denied');
};
checkPermissions();
refreshPendingCount();
}, [refreshPendingCount]);
// Auto-start tracking si está configurado
useEffect(() => {
if (autoStart && permissionStatus === 'granted') {
startTracking(viajeId);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [autoStart, permissionStatus, viajeId]);
// Solicitar permisos
const requestPermissions = useCallback(async (): Promise<boolean> => {
setIsLoading(true);
setError(null);
try {
const granted = await requestLocationPermissions();
if (granted) {
const permissions = await checkLocationPermissions();
setPermissionStatus(permissions.foreground ? 'granted' : 'denied');
setBackgroundPermission(permissions.background ? 'granted' : 'denied');
return true;
}
setPermissionStatus('denied');
setError('Permisos de ubicación denegados');
return false;
} catch (err) {
setError('Error solicitando permisos');
return false;
} finally {
setIsLoading(false);
}
}, []);
// Iniciar tracking
const startTracking = useCallback(
async (tripId?: string): Promise<boolean> => {
if (isTracking) return true;
setIsLoading(true);
setError(null);
try {
const started = await startLocationTracking(tripId || viajeId);
if (started) {
setIsTracking(true);
// Configurar intervalo de actualización de UI
intervalRef.current = setInterval(async () => {
const location = await getCurrentLocation();
if (location) {
setUbicacionActual(location);
}
}, updateInterval);
// Obtener ubicación inicial
const initialLocation = await getCurrentLocation();
if (initialLocation) {
setUbicacionActual(initialLocation);
}
return true;
}
setError('No se pudo iniciar el tracking');
return false;
} catch (err) {
setError('Error iniciando tracking');
return false;
} finally {
setIsLoading(false);
}
},
[isTracking, viajeId, updateInterval, setUbicacionActual]
);
// Detener tracking
const stopTracking = useCallback(async (): Promise<void> => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
await stopLocationTracking();
setIsTracking(false);
}, []);
// Refrescar ubicación manualmente
const refreshLocation = useCallback(async (): Promise<Ubicacion | null> => {
setIsLoading(true);
try {
const location = await getCurrentLocation();
if (location) {
setUbicacionActual(location);
return location;
}
return null;
} catch (err) {
setError('Error obteniendo ubicación');
return null;
} finally {
setIsLoading(false);
}
}, [setUbicacionActual]);
// Sincronizar ubicaciones pendientes
const syncPending = useCallback(async (): Promise<number> => {
try {
const synced = await syncPendingLocations();
await refreshPendingCount();
return synced;
} catch (err) {
console.error('Error sincronizando:', err);
return 0;
}
}, [syncPendingLocations, refreshPendingCount]);
// Calcular distancia a un punto
const getDistanceTo = useCallback(
(lat: number, lon: number): number | null => {
if (!ubicacionActual) return null;
return calculateDistance(
ubicacionActual.latitud,
ubicacionActual.longitud,
lat,
lon
);
},
[ubicacionActual]
);
return {
// Estado
ubicacion: ubicacionActual,
isTracking,
isLoading,
permissionStatus,
backgroundPermission,
pendingCount,
isOnline,
error,
// Acciones
requestPermissions,
startTracking,
stopTracking,
refreshLocation,
syncPending,
getDistanceTo,
};
};
export default useLocation;

View File

@@ -0,0 +1,232 @@
/**
* Hook personalizado para gestión de viajes
* Proporciona acceso al viaje activo y acciones de control
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useViajeStore } from '../store/viajeStore';
import { useLocation } from './useLocation';
import type { Viaje, Parada, TipoParada } from '../types';
interface UseViajeReturn {
// Estado del viaje
viaje: Viaje | null;
proximoViaje: Viaje | null;
paradaActual: Parada | null;
isLoading: boolean;
isTracking: boolean;
error: string | null;
// Estadísticas
viajesHoy: number;
distanciaHoy: number;
tiempoHoy: number;
progreso: number;
// Info calculada
distanciaRestante: number;
tiempoEstimadoRestante: number;
distanciaAParada: number | null;
// Acciones de viaje
cargarViajeActivo: () => Promise<void>;
cargarProximoViaje: () => Promise<void>;
iniciar: (viajeId: string) => Promise<{ success: boolean; error?: string }>;
pausar: (motivo?: string) => Promise<{ success: boolean; error?: string }>;
reanudar: () => Promise<{ success: boolean; error?: string }>;
finalizar: (notas?: string) => Promise<{ success: boolean; error?: string }>;
// Acciones de paradas
llegarAParada: (paradaId: string) => Promise<{ success: boolean; error?: string }>;
salirDeParada: (
paradaId: string,
notas?: string
) => Promise<{ success: boolean; error?: string }>;
registrarParada: (data: {
tipo: TipoParada;
notas?: string;
}) => Promise<{ success: boolean; error?: string }>;
// Utilidades
limpiarError: () => void;
}
export const useViaje = (): UseViajeReturn => {
const {
viajeActivo,
proximoViaje,
paradaActual,
isLoading,
isTracking,
error,
viajesHoy,
distanciaHoy,
tiempoConduccionHoy,
fetchViajeActivo,
fetchProximoViaje,
iniciarViaje,
pausarViaje,
reanudarViaje,
finalizarViaje,
registrarLlegadaParada,
registrarSalidaParada,
registrarParadaNoProgramada,
clearError,
} = useViajeStore();
const { ubicacion, getDistanceTo } = useLocation({
autoStart: !!viajeActivo && viajeActivo.estado === 'en_curso',
viajeId: viajeActivo?.id,
});
// Cargar viaje activo al montar
useEffect(() => {
fetchViajeActivo();
fetchProximoViaje();
}, [fetchViajeActivo, fetchProximoViaje]);
// Calcular progreso del viaje
const progreso = useMemo(() => {
if (!viajeActivo || !viajeActivo.distanciaEstimada) return 0;
const recorrido = viajeActivo.distanciaRecorrida || 0;
return Math.min(100, (recorrido / viajeActivo.distanciaEstimada) * 100);
}, [viajeActivo]);
// Calcular distancia restante
const distanciaRestante = useMemo(() => {
if (!viajeActivo) return 0;
const estimada = viajeActivo.distanciaEstimada || 0;
const recorrida = viajeActivo.distanciaRecorrida || 0;
return Math.max(0, estimada - recorrida);
}, [viajeActivo]);
// Calcular tiempo estimado restante
const tiempoEstimadoRestante = useMemo(() => {
if (!viajeActivo) return 0;
const estimado = viajeActivo.tiempoEstimado || 0;
const transcurrido = viajeActivo.tiempoTranscurrido || 0;
return Math.max(0, estimado - transcurrido);
}, [viajeActivo]);
// Calcular distancia a la parada actual
const distanciaAParada = useMemo(() => {
if (!paradaActual || !ubicacion) return null;
return getDistanceTo(
paradaActual.punto.latitud,
paradaActual.punto.longitud
);
}, [paradaActual, ubicacion, getDistanceTo]);
// Cargar viaje activo
const cargarViajeActivo = useCallback(async () => {
await fetchViajeActivo();
}, [fetchViajeActivo]);
// Cargar próximo viaje
const cargarProximoViaje = useCallback(async () => {
await fetchProximoViaje();
}, [fetchProximoViaje]);
// Iniciar viaje
const iniciar = useCallback(
async (viajeId: string) => {
return await iniciarViaje(viajeId);
},
[iniciarViaje]
);
// Pausar viaje
const pausar = useCallback(
async (motivo?: string) => {
return await pausarViaje(motivo);
},
[pausarViaje]
);
// Reanudar viaje
const reanudar = useCallback(async () => {
return await reanudarViaje();
}, [reanudarViaje]);
// Finalizar viaje
const finalizar = useCallback(
async (notas?: string) => {
return await finalizarViaje(notas);
},
[finalizarViaje]
);
// Registrar llegada a parada
const llegarAParada = useCallback(
async (paradaId: string) => {
return await registrarLlegadaParada(paradaId);
},
[registrarLlegadaParada]
);
// Registrar salida de parada
const salirDeParada = useCallback(
async (paradaId: string, notas?: string) => {
return await registrarSalidaParada(paradaId, notas);
},
[registrarSalidaParada]
);
// Registrar parada no programada
const registrarParada = useCallback(
async (data: { tipo: TipoParada; notas?: string }) => {
return await registrarParadaNoProgramada(data);
},
[registrarParadaNoProgramada]
);
// Limpiar error
const limpiarError = useCallback(() => {
clearError();
}, [clearError]);
return {
// Estado
viaje: viajeActivo,
proximoViaje,
paradaActual,
isLoading,
isTracking,
error,
// Estadísticas
viajesHoy,
distanciaHoy,
tiempoHoy: tiempoConduccionHoy,
progreso,
// Info calculada
distanciaRestante,
tiempoEstimadoRestante,
distanciaAParada,
// Acciones de viaje
cargarViajeActivo,
cargarProximoViaje,
iniciar,
pausar,
reanudar,
finalizar,
// Acciones de paradas
llegarAParada,
salirDeParada,
registrarParada,
// Utilidades
limpiarError,
};
};
export default useViaje;

View File

@@ -0,0 +1,357 @@
/**
* Pantalla Camara - Dashcam para grabar video
*/
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
TouchableOpacity,
Alert,
Dimensions,
} from 'react-native';
import { Camera, CameraType, CameraView } from 'expo-camera';
import { useNavigation } from '@react-navigation/native';
import { Button } from '../components';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
FONT_WEIGHT,
} from '../utils/theme';
import { formatDuration } from '../utils/helpers';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export const CamaraScreen: React.FC = () => {
const navigation = useNavigation();
const cameraRef = useRef<CameraView>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [cameraType, setCameraType] = useState<CameraType>('back');
const recordingInterval = useRef<NodeJS.Timeout | null>(null);
// Solicitar permisos
useEffect(() => {
(async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
const { status: micStatus } = await Camera.requestMicrophonePermissionsAsync();
setHasPermission(status === 'granted' && micStatus === 'granted');
})();
return () => {
if (recordingInterval.current) {
clearInterval(recordingInterval.current);
}
};
}, []);
// Iniciar grabación
const startRecording = async () => {
if (!cameraRef.current || isRecording) return;
try {
setIsRecording(true);
setRecordingTime(0);
// Contador de tiempo
recordingInterval.current = setInterval(() => {
setRecordingTime((prev) => prev + 1);
}, 1000);
const video = await cameraRef.current.recordAsync({
maxDuration: 300, // 5 minutos máximo
});
console.log('Video grabado:', video);
// Aquí se podría subir el video o guardarlo localmente
} catch (error) {
console.error('Error grabando:', error);
Alert.alert('Error', 'No se pudo grabar el video');
}
};
// Detener grabación
const stopRecording = async () => {
if (!cameraRef.current || !isRecording) return;
try {
cameraRef.current.stopRecording();
setIsRecording(false);
if (recordingInterval.current) {
clearInterval(recordingInterval.current);
}
Alert.alert(
'Video guardado',
`Se grabaron ${formatDuration(recordingTime / 60)} de video`,
[{ text: 'OK' }]
);
} catch (error) {
console.error('Error deteniendo grabación:', error);
}
};
// Cambiar cámara
const toggleCameraType = () => {
setCameraType((current) => (current === 'back' ? 'front' : 'back'));
};
// Sin permisos
if (hasPermission === null) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.messageText}>Solicitando permisos de cámara...</Text>
</View>
</SafeAreaView>
);
}
if (hasPermission === false) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centered}>
<Text style={styles.messageEmoji}>📷</Text>
<Text style={styles.messageTitle}>Sin acceso a la cámara</Text>
<Text style={styles.messageText}>
Necesitamos acceso a la cámara y micrófono para la función de
dashcam. Por favor, habilita los permisos en la configuración.
</Text>
<Button
title="Volver"
onPress={() => navigation.goBack()}
variant="primary"
size="lg"
style={styles.backButton}
/>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing={cameraType}
mode="video"
>
{/* Overlay superior */}
<SafeAreaView style={styles.overlay}>
<View style={styles.topBar}>
<TouchableOpacity
style={styles.closeButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.closeText}></Text>
</TouchableOpacity>
{isRecording && (
<View style={styles.recordingIndicator}>
<View style={styles.recordingDot} />
<Text style={styles.recordingTime}>
{formatDuration(recordingTime / 60)}
</Text>
</View>
)}
<TouchableOpacity
style={styles.flipButton}
onPress={toggleCameraType}
disabled={isRecording}
>
<Text style={styles.flipText}>🔄</Text>
</TouchableOpacity>
</View>
{/* Controles inferiores */}
<View style={styles.bottomBar}>
<View style={styles.controls}>
{/* Info de dashcam */}
<View style={styles.infoBox}>
<Text style={styles.infoText}>
{isRecording
? 'Grabando... Toca para detener'
: 'Dashcam lista. Toca para grabar'}
</Text>
</View>
{/* Botón de grabación */}
<TouchableOpacity
style={[
styles.recordButton,
isRecording && styles.recordButtonActive,
]}
onPress={isRecording ? stopRecording : startRecording}
>
<View
style={[
styles.recordButtonInner,
isRecording && styles.recordButtonInnerActive,
]}
/>
</TouchableOpacity>
{/* Info adicional */}
<View style={styles.infoBox}>
<Text style={styles.infoTextSmall}>
Máximo 5 min por grabación
</Text>
</View>
</View>
</View>
</SafeAreaView>
</CameraView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.black,
},
camera: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'transparent',
},
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: SPACING.md,
paddingTop: SPACING.md,
},
closeButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
},
closeText: {
fontSize: 20,
color: COLORS.white,
},
recordingIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.full,
},
recordingDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: COLORS.danger,
marginRight: SPACING.sm,
},
recordingTime: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
flipButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
},
flipText: {
fontSize: 24,
},
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingBottom: SPACING.xxl,
},
controls: {
alignItems: 'center',
},
infoBox: {
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.full,
marginBottom: SPACING.md,
},
infoText: {
fontSize: FONT_SIZE.md,
color: COLORS.white,
textAlign: 'center',
},
infoTextSmall: {
fontSize: FONT_SIZE.sm,
color: 'rgba(255,255,255,0.7)',
textAlign: 'center',
},
recordButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(255,255,255,0.3)',
alignItems: 'center',
justifyContent: 'center',
marginVertical: SPACING.md,
},
recordButtonActive: {
backgroundColor: 'rgba(255,0,0,0.3)',
},
recordButtonInner: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: COLORS.danger,
},
recordButtonInnerActive: {
width: 32,
height: 32,
borderRadius: 8,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: SPACING.xl,
},
messageEmoji: {
fontSize: 64,
marginBottom: SPACING.md,
},
messageTitle: {
fontSize: FONT_SIZE.xl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
messageText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
textAlign: 'center',
lineHeight: 24,
},
backButton: {
marginTop: SPACING.xl,
},
});
export default CamaraScreen;

View File

@@ -0,0 +1,389 @@
/**
* Pantalla Combustible - Registro de cargas de combustible
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
Alert,
TouchableOpacity,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Button, Input, LoadingOverlay } from '../components';
import { useAuth, useLocation, useViaje } from '../hooks';
import { combustibleApi } from '../services/api';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import { formatCurrency } from '../utils/helpers';
import type { TipoCombustible, CargaCombustible } from '../types';
const TIPOS_COMBUSTIBLE: Array<{ tipo: TipoCombustible; nombre: string; emoji: string }> = [
{ tipo: 'diesel', nombre: 'Diésel', emoji: '🛢️' },
{ tipo: 'gasolina', nombre: 'Gasolina', emoji: '⛽' },
{ tipo: 'gas', nombre: 'Gas LP', emoji: '🔥' },
];
export const CombustibleScreen: React.FC = () => {
const navigation = useNavigation();
const { conductor } = useAuth();
const { viaje } = useViaje();
const { ubicacion } = useLocation();
const [isLoading, setIsLoading] = useState(false);
// Formulario
const [litros, setLitros] = useState('');
const [precioLitro, setPrecioLitro] = useState('');
const [odometro, setOdometro] = useState('');
const [estacion, setEstacion] = useState('');
const [tipoCombustible, setTipoCombustible] = useState<TipoCombustible>('diesel');
// Calcular total
const total =
litros && precioLitro
? parseFloat(litros) * parseFloat(precioLitro)
: 0;
const handleGuardar = async () => {
// Validaciones
if (!litros || parseFloat(litros) <= 0) {
Alert.alert('Error', 'Ingresa la cantidad de litros');
return;
}
if (!precioLitro || parseFloat(precioLitro) <= 0) {
Alert.alert('Error', 'Ingresa el precio por litro');
return;
}
if (!odometro || parseInt(odometro) <= 0) {
Alert.alert('Error', 'Ingresa el odómetro actual');
return;
}
if (!estacion.trim()) {
Alert.alert('Error', 'Ingresa el nombre de la estación');
return;
}
setIsLoading(true);
try {
const data: Omit<CargaCombustible, 'id'> = {
viajeId: viaje?.id,
conductorId: conductor?.id || '',
vehiculoId: conductor?.vehiculoAsignado?.id || '',
fecha: new Date().toISOString(),
litros: parseFloat(litros),
precioLitro: parseFloat(precioLitro),
total,
odometro: parseInt(odometro),
estacion: estacion.trim(),
tipoCombustible,
ubicacion: ubicacion || undefined,
};
const response = await combustibleApi.registrarCarga(data);
if (response.success) {
Alert.alert(
'Carga registrada',
`Se registró la carga de ${litros}L por ${formatCurrency(total)}`,
[{ text: 'OK', onPress: () => navigation.goBack() }]
);
} else {
Alert.alert('Error', response.error || 'No se pudo registrar la carga');
}
} catch (error) {
Alert.alert('Error', 'No se pudo registrar la carga de combustible');
} finally {
setIsLoading(false);
}
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Cargar Combustible</Text>
<Text style={styles.subtitle}>
Registra los datos de la carga
</Text>
</View>
{/* Tipo de combustible */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Tipo de combustible</Text>
<View style={styles.tiposContainer}>
{TIPOS_COMBUSTIBLE.map(({ tipo, nombre, emoji }) => (
<TouchableOpacity
key={tipo}
style={[
styles.tipoButton,
tipoCombustible === tipo && styles.tipoButtonSelected,
]}
onPress={() => setTipoCombustible(tipo)}
>
<Text style={styles.tipoEmoji}>{emoji}</Text>
<Text
style={[
styles.tipoText,
tipoCombustible === tipo && styles.tipoTextSelected,
]}
>
{nombre}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Formulario */}
<View style={styles.section}>
<Input
label="Litros"
placeholder="0.00"
value={litros}
onChangeText={setLitros}
keyboardType="decimal-pad"
size="lg"
/>
<Input
label="Precio por litro"
placeholder="0.00"
value={precioLitro}
onChangeText={setPrecioLitro}
keyboardType="decimal-pad"
size="lg"
leftIcon={<Text style={styles.currencySymbol}>$</Text>}
/>
<Input
label="Odómetro actual (km)"
placeholder="0"
value={odometro}
onChangeText={setOdometro}
keyboardType="number-pad"
size="lg"
/>
<Input
label="Estación de servicio"
placeholder="Nombre de la gasolinera"
value={estacion}
onChangeText={setEstacion}
size="lg"
autoCapitalize="words"
/>
</View>
{/* Resumen */}
{total > 0 && (
<View style={[styles.resumenCard, SHADOWS.md]}>
<Text style={styles.resumenTitle}>Resumen</Text>
<View style={styles.resumenRow}>
<Text style={styles.resumenLabel}>Litros:</Text>
<Text style={styles.resumenValue}>{litros} L</Text>
</View>
<View style={styles.resumenRow}>
<Text style={styles.resumenLabel}>Precio/litro:</Text>
<Text style={styles.resumenValue}>
{formatCurrency(parseFloat(precioLitro) || 0)}
</Text>
</View>
<View style={[styles.resumenRow, styles.resumenTotal]}>
<Text style={styles.resumenTotalLabel}>Total:</Text>
<Text style={styles.resumenTotalValue}>
{formatCurrency(total)}
</Text>
</View>
</View>
)}
{/* Foto del ticket (placeholder) */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ticket (opcional)</Text>
<TouchableOpacity
style={[styles.photoButton, SHADOWS.sm]}
onPress={() =>
Alert.alert('Proximamente', 'Función de fotos en desarrollo')
}
>
<Text style={styles.photoButtonEmoji}>📸</Text>
<Text style={styles.photoButtonText}>Tomar foto del ticket</Text>
</TouchableOpacity>
</View>
</ScrollView>
{/* Botón guardar */}
<View style={styles.footer}>
<Button
title="Registrar Carga"
onPress={handleGuardar}
variant="primary"
size="xl"
fullWidth
loading={isLoading}
disabled={!litros || !precioLitro || !odometro || !estacion}
/>
</View>
<LoadingOverlay visible={isLoading} message="Registrando carga..." />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: SPACING.md,
paddingBottom: SPACING.xxl,
},
header: {
marginBottom: SPACING.lg,
},
title: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
subtitle: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
section: {
marginBottom: SPACING.lg,
},
sectionTitle: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
tiposContainer: {
flexDirection: 'row',
gap: SPACING.sm,
},
tipoButton: {
flex: 1,
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
alignItems: 'center',
borderWidth: 2,
borderColor: COLORS.border,
...SHADOWS.sm,
},
tipoButtonSelected: {
borderColor: COLORS.primary,
backgroundColor: `${COLORS.primary}05`,
},
tipoEmoji: {
fontSize: 32,
marginBottom: SPACING.xs,
},
tipoText: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textSecondary,
},
tipoTextSelected: {
color: COLORS.primary,
},
currencySymbol: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textSecondary,
},
resumenCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
marginBottom: SPACING.lg,
},
resumenTitle: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.md,
},
resumenRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: SPACING.sm,
},
resumenLabel: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
resumenValue: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textPrimary,
},
resumenTotal: {
borderTopWidth: 1,
borderTopColor: COLORS.border,
paddingTop: SPACING.sm,
marginTop: SPACING.sm,
marginBottom: 0,
},
resumenTotalLabel: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
},
resumenTotalValue: {
fontSize: FONT_SIZE.xl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.primary,
},
photoButton: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
alignItems: 'center',
borderWidth: 2,
borderColor: COLORS.border,
borderStyle: 'dashed',
},
photoButtonEmoji: {
fontSize: 40,
marginBottom: SPACING.sm,
},
photoButtonText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
footer: {
padding: SPACING.md,
backgroundColor: COLORS.white,
borderTopWidth: 1,
borderTopColor: COLORS.border,
},
});
export default CombustibleScreen;

View File

@@ -0,0 +1,546 @@
/**
* Pantalla Emergencia - Botón SOS y reporte de emergencias
*/
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
TouchableOpacity,
Alert,
Vibration,
Animated,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Button, LoadingOverlay } from '../components';
import { useAuth, useLocation, useViaje } from '../hooks';
import { emergenciaApi } from '../services/api';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import type { TipoEmergencia, Emergencia } from '../types';
const TIPOS_EMERGENCIA: Array<{
tipo: TipoEmergencia;
emoji: string;
nombre: string;
descripcion: string;
}> = [
{
tipo: 'accidente',
emoji: '🚗',
nombre: 'Accidente',
descripcion: 'Choque o accidente vial',
},
{
tipo: 'robo',
emoji: '🚨',
nombre: 'Robo/Asalto',
descripcion: 'Situación de robo',
},
{
tipo: 'mecanico',
emoji: '🔧',
nombre: 'Avería',
descripcion: 'Problema mecánico grave',
},
{
tipo: 'salud',
emoji: '🏥',
nombre: 'Salud',
descripcion: 'Emergencia médica',
},
{
tipo: 'otro',
emoji: '⚠️',
nombre: 'Otro',
descripcion: 'Otra emergencia',
},
];
export const EmergenciaScreen: React.FC = () => {
const navigation = useNavigation();
const { conductor } = useAuth();
const { viaje } = useViaje();
const { ubicacion, refreshLocation } = useLocation();
const [isLoading, setIsLoading] = useState(false);
const [tipoSeleccionado, setTipoSeleccionado] = useState<TipoEmergencia | null>(
null
);
const [emergenciaActiva, setEmergenciaActiva] = useState<Emergencia | null>(null);
const [sosPresionado, setSosPresionado] = useState(false);
const [countdown, setCountdown] = useState(3);
// Animación del botón SOS
const pulseAnim = useRef(new Animated.Value(1)).current;
const countdownRef = useRef<NodeJS.Timeout | null>(null);
// Animación de pulso
useEffect(() => {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}, [pulseAnim]);
// Limpiar countdown al desmontar
useEffect(() => {
return () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
}
};
}, []);
// Iniciar countdown SOS
const iniciarSOS = () => {
setSosPresionado(true);
setCountdown(3);
Vibration.vibrate([0, 200, 100, 200]);
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) clearInterval(countdownRef.current);
enviarSOS();
return 0;
}
Vibration.vibrate(200);
return prev - 1;
});
}, 1000);
};
// Cancelar countdown
const cancelarSOS = () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
}
setSosPresionado(false);
setCountdown(3);
Vibration.cancel();
};
// Enviar SOS inmediato
const enviarSOS = async () => {
setSosPresionado(false);
setIsLoading(true);
Vibration.vibrate([0, 500, 200, 500]);
try {
// Refrescar ubicación
await refreshLocation();
if (!ubicacion) {
Alert.alert('Error', 'No se pudo obtener tu ubicación');
setIsLoading(false);
return;
}
const response = await emergenciaApi.reportarEmergencia({
conductorId: conductor?.id || '',
viajeId: viaje?.id,
tipo: tipoSeleccionado || 'otro',
ubicacion,
fecha: new Date().toISOString(),
});
if (response.success && response.data) {
setEmergenciaActiva(response.data);
Alert.alert(
'Alerta Enviada',
'Se ha enviado la alerta de emergencia. El equipo de soporte ha sido notificado.',
[{ text: 'OK' }]
);
} else {
Alert.alert('Error', response.error || 'No se pudo enviar la alerta');
}
} catch (error) {
Alert.alert('Error', 'No se pudo enviar la alerta de emergencia');
} finally {
setIsLoading(false);
}
};
// Cancelar emergencia activa
const cancelarEmergencia = async () => {
if (!emergenciaActiva) return;
Alert.alert(
'Cancelar Emergencia',
'Estas seguro de que quieres cancelar la alerta de emergencia?',
[
{ text: 'No', style: 'cancel' },
{
text: 'Si, cancelar',
style: 'destructive',
onPress: async () => {
setIsLoading(true);
try {
await emergenciaApi.cancelarEmergencia(emergenciaActiva.id);
setEmergenciaActiva(null);
Alert.alert('Emergencia cancelada', 'La alerta ha sido cancelada');
} catch (error) {
Alert.alert('Error', 'No se pudo cancelar la alerta');
} finally {
setIsLoading(false);
}
},
},
]
);
};
// Si hay emergencia activa, mostrar estado
if (emergenciaActiva) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.emergenciaActiva}>
<View style={styles.alertaActiva}>
<Text style={styles.alertaEmoji}>🚨</Text>
<Text style={styles.alertaTitle}>Alerta Activa</Text>
<Text style={styles.alertaText}>
El equipo de soporte ha sido notificado y está atendiendo tu
emergencia.
</Text>
</View>
<View style={styles.infoBox}>
<Text style={styles.infoLabel}>Tipo de emergencia:</Text>
<Text style={styles.infoValue}>
{TIPOS_EMERGENCIA.find((t) => t.tipo === emergenciaActiva.tipo)
?.nombre || 'Otro'}
</Text>
</View>
<View style={styles.infoBox}>
<Text style={styles.infoLabel}>Ubicación compartida:</Text>
<Text style={styles.infoValue}>
{emergenciaActiva.ubicacion.latitud.toFixed(6)},{' '}
{emergenciaActiva.ubicacion.longitud.toFixed(6)}
</Text>
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactTitle}>Contacto de emergencia</Text>
<Text style={styles.contactNumber}>📞 800-123-4567</Text>
</View>
<Button
title="Cancelar Alerta"
onPress={cancelarEmergencia}
variant="outline"
size="lg"
fullWidth
style={styles.cancelButton}
/>
</View>
<LoadingOverlay visible={isLoading} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Emergencia</Text>
<Text style={styles.subtitle}>
{sosPresionado
? 'Mantén presionado o suelta para cancelar'
: 'Selecciona el tipo de emergencia y presiona SOS'}
</Text>
</View>
{/* Tipos de emergencia */}
{!sosPresionado && (
<View style={styles.tiposContainer}>
{TIPOS_EMERGENCIA.map(({ tipo, emoji, nombre, descripcion }) => (
<TouchableOpacity
key={tipo}
style={[
styles.tipoCard,
tipoSeleccionado === tipo && styles.tipoCardSelected,
]}
onPress={() => setTipoSeleccionado(tipo)}
>
<Text style={styles.tipoEmoji}>{emoji}</Text>
<Text
style={[
styles.tipoNombre,
tipoSeleccionado === tipo && styles.tipoNombreSelected,
]}
>
{nombre}
</Text>
</TouchableOpacity>
))}
</View>
)}
{/* Botón SOS */}
<View style={styles.sosContainer}>
{sosPresionado && (
<Text style={styles.countdownText}>{countdown}</Text>
)}
<TouchableOpacity
onPressIn={iniciarSOS}
onPressOut={cancelarSOS}
activeOpacity={0.8}
>
<Animated.View
style={[
styles.sosButton,
sosPresionado && styles.sosButtonPressed,
{ transform: [{ scale: pulseAnim }] },
]}
>
<Text style={styles.sosText}>SOS</Text>
{!sosPresionado && (
<Text style={styles.sosSubtext}>Mantén presionado</Text>
)}
</Animated.View>
</TouchableOpacity>
</View>
{/* Info */}
<View style={styles.infoContainer}>
<Text style={styles.infoText}>
Al activar la alerta, se compartirá tu ubicación y se notificará
inmediatamente al equipo de soporte.
</Text>
</View>
{/* Contacto directo */}
<TouchableOpacity style={styles.llamarButton}>
<Text style={styles.llamarEmoji}>📞</Text>
<View>
<Text style={styles.llamarText}>Llamar a emergencias</Text>
<Text style={styles.llamarNumber}>800-123-4567</Text>
</View>
</TouchableOpacity>
</View>
<LoadingOverlay visible={isLoading} message="Enviando alerta..." />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
content: {
flex: 1,
padding: SPACING.md,
},
header: {
alignItems: 'center',
marginBottom: SPACING.lg,
},
title: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.danger,
marginBottom: SPACING.xs,
},
subtitle: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
textAlign: 'center',
},
tiposContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: SPACING.sm,
marginBottom: SPACING.xl,
},
tipoCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
alignItems: 'center',
minWidth: 80,
borderWidth: 2,
borderColor: 'transparent',
...SHADOWS.sm,
},
tipoCardSelected: {
borderColor: COLORS.danger,
backgroundColor: `${COLORS.danger}05`,
},
tipoEmoji: {
fontSize: 28,
marginBottom: SPACING.xs,
},
tipoNombre: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textSecondary,
},
tipoNombreSelected: {
color: COLORS.danger,
},
sosContainer: {
alignItems: 'center',
marginBottom: SPACING.xl,
},
countdownText: {
fontSize: 64,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.danger,
marginBottom: SPACING.md,
},
sosButton: {
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: COLORS.danger,
alignItems: 'center',
justifyContent: 'center',
...SHADOWS.lg,
},
sosButtonPressed: {
backgroundColor: COLORS.dangerDark,
transform: [{ scale: 0.95 }],
},
sosText: {
fontSize: 48,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
sosSubtext: {
fontSize: FONT_SIZE.sm,
color: 'rgba(255,255,255,0.8)',
marginTop: SPACING.xs,
},
infoContainer: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
marginBottom: SPACING.lg,
...SHADOWS.sm,
},
infoText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
textAlign: 'center',
lineHeight: 20,
},
llamarButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
...SHADOWS.sm,
},
llamarEmoji: {
fontSize: 32,
marginRight: SPACING.md,
},
llamarText: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
},
llamarNumber: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.primary,
},
// Emergencia activa
emergenciaActiva: {
flex: 1,
padding: SPACING.md,
},
alertaActiva: {
backgroundColor: `${COLORS.danger}10`,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.xl,
alignItems: 'center',
marginBottom: SPACING.lg,
borderWidth: 2,
borderColor: COLORS.danger,
},
alertaEmoji: {
fontSize: 64,
marginBottom: SPACING.md,
},
alertaTitle: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.danger,
marginBottom: SPACING.sm,
},
alertaText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
textAlign: 'center',
},
infoBox: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
marginBottom: SPACING.sm,
...SHADOWS.sm,
},
infoLabel: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
marginBottom: 2,
},
infoValue: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textPrimary,
},
contactInfo: {
backgroundColor: COLORS.primary,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
alignItems: 'center',
marginVertical: SPACING.lg,
},
contactTitle: {
fontSize: FONT_SIZE.sm,
color: 'rgba(255,255,255,0.8)',
marginBottom: SPACING.xs,
},
contactNumber: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
cancelButton: {
marginTop: 'auto',
},
});
export default EmergenciaScreen;

443
mobile/src/screens/Home.tsx Normal file
View File

@@ -0,0 +1,443 @@
/**
* Pantalla Home - Dashboard principal del conductor
*/
import React, { useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
RefreshControl,
TouchableOpacity,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import {
Button,
StatCard,
ViajeCard,
MapView,
LoadingSpinner,
} from '../components';
import { useAuth, useViaje, useLocation } from '../hooks';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import {
formatDistance,
formatDuration,
getEstadoViajeInfo,
} from '../utils/helpers';
import type { MainTabParamList } from '../types';
type HomeNavigationProp = BottomTabNavigationProp<MainTabParamList, 'Home'>;
export const HomeScreen: React.FC = () => {
const navigation = useNavigation<HomeNavigationProp>();
const { conductor } = useAuth();
const {
viaje,
proximoViaje,
isLoading,
viajesHoy,
distanciaHoy,
tiempoHoy,
cargarViajeActivo,
cargarProximoViaje,
iniciar,
} = useViaje();
const { ubicacion, isOnline, pendingCount, refreshLocation } = useLocation();
const [refreshing, setRefreshing] = React.useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.all([cargarViajeActivo(), cargarProximoViaje(), refreshLocation()]);
setRefreshing(false);
}, [cargarViajeActivo, cargarProximoViaje, refreshLocation]);
const handleIniciarViaje = async () => {
if (!proximoViaje) return;
Alert.alert(
'Iniciar Viaje',
`Vas a iniciar el viaje a ${proximoViaje.destino.nombre}. Asegurate de estar listo.`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Iniciar',
onPress: async () => {
const result = await iniciar(proximoViaje.id);
if (result.success) {
navigation.navigate('Viaje');
} else {
Alert.alert('Error', result.error || 'No se pudo iniciar el viaje');
}
},
},
]
);
};
const handleVerViajeActivo = () => {
navigation.navigate('Viaje');
};
const getGreeting = (): string => {
const hour = new Date().getHours();
if (hour < 12) return 'Buenos días';
if (hour < 18) return 'Buenas tardes';
return 'Buenas noches';
};
const estadoInfo = viaje ? getEstadoViajeInfo(viaje.estado) : null;
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.greeting}>{getGreeting()}</Text>
<Text style={styles.conductorName}>
{conductor?.nombre} {conductor?.apellido}
</Text>
</View>
<View style={styles.statusBadges}>
{!isOnline && (
<View style={[styles.badge, styles.offlineBadge]}>
<Text style={styles.badgeText}>Offline</Text>
</View>
)}
{pendingCount > 0 && (
<View style={[styles.badge, styles.pendingBadge]}>
<Text style={styles.badgeText}>{pendingCount} pendientes</Text>
</View>
)}
</View>
</View>
{/* Mapa con ubicación actual */}
<View style={styles.mapSection}>
<MapView
ubicacionActual={ubicacion}
showUserLocation
height={180}
zoomLevel={14}
/>
</View>
{/* Viaje activo */}
{viaje && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Viaje Activo</Text>
<View
style={[
styles.estadoBadge,
{ backgroundColor: `${estadoInfo?.color}15` },
]}
>
<Text style={[styles.estadoText, { color: estadoInfo?.color }]}>
{estadoInfo?.label}
</Text>
</View>
</View>
<ViajeCard viaje={viaje} onPress={handleVerViajeActivo} />
<Button
title="Ver viaje activo"
onPress={handleVerViajeActivo}
size="lg"
fullWidth
style={styles.actionButton}
/>
</View>
)}
{/* Próximo viaje */}
{!viaje && proximoViaje && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Próximo Viaje</Text>
<ViajeCard viaje={proximoViaje} />
<Button
title="Iniciar Viaje"
onPress={handleIniciarViaje}
variant="success"
size="xl"
fullWidth
style={styles.actionButton}
/>
</View>
)}
{/* Sin viajes */}
{!viaje && !proximoViaje && !isLoading && (
<View style={styles.emptyState}>
<View style={styles.emptyIcon}>
<Text style={styles.emptyIconText}>🚛</Text>
</View>
<Text style={styles.emptyTitle}>Sin viajes pendientes</Text>
<Text style={styles.emptySubtitle}>
Cuando te asignen un viaje, aparecerá aquí
</Text>
</View>
)}
{/* Estadísticas del día */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumen del día</Text>
<View style={styles.statsGrid}>
<StatCard
title="Viajes"
value={viajesHoy}
subtitle="completados"
color={COLORS.success}
size="sm"
style={styles.statCard}
/>
<StatCard
title="Distancia"
value={formatDistance(distanciaHoy)}
subtitle="recorrida"
color={COLORS.primary}
size="sm"
style={styles.statCard}
/>
<StatCard
title="Tiempo"
value={formatDuration(tiempoHoy)}
subtitle="conduciendo"
color={COLORS.info}
size="sm"
style={styles.statCard}
/>
</View>
</View>
{/* Acciones rápidas */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Acciones rápidas</Text>
<View style={styles.quickActions}>
<TouchableOpacity
style={styles.quickAction}
onPress={() => navigation.navigate('Mensajes' as any)}
>
<View style={[styles.quickActionIcon, { backgroundColor: `${COLORS.primary}15` }]}>
<Text style={styles.quickActionEmoji}>💬</Text>
</View>
<Text style={styles.quickActionText}>Mensajes</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => navigation.navigate('Perfil' as any)}
>
<View style={[styles.quickActionIcon, { backgroundColor: `${COLORS.success}15` }]}>
<Text style={styles.quickActionEmoji}>📊</Text>
</View>
<Text style={styles.quickActionText}>Estadísticas</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => Alert.alert('Emergencia', 'Usa el botón SOS en la pantalla de viaje')}
>
<View style={[styles.quickActionIcon, { backgroundColor: `${COLORS.danger}15` }]}>
<Text style={styles.quickActionEmoji}>🆘</Text>
</View>
<Text style={styles.quickActionText}>Emergencia</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{isLoading && (
<View style={styles.loadingOverlay}>
<LoadingSpinner message="Cargando..." />
</View>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: SPACING.md,
paddingBottom: SPACING.xxl,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: SPACING.md,
},
headerLeft: {
flex: 1,
},
greeting: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
conductorName: {
fontSize: FONT_SIZE.xl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
},
statusBadges: {
flexDirection: 'row',
gap: SPACING.xs,
},
badge: {
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
offlineBadge: {
backgroundColor: COLORS.danger,
},
pendingBadge: {
backgroundColor: COLORS.warning,
},
badgeText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.white,
},
mapSection: {
marginBottom: SPACING.md,
borderRadius: BORDER_RADIUS.xl,
overflow: 'hidden',
...SHADOWS.md,
},
section: {
marginBottom: SPACING.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.sm,
},
sectionTitle: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
estadoBadge: {
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
estadoText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.semibold,
},
actionButton: {
marginTop: SPACING.md,
},
emptyState: {
alignItems: 'center',
paddingVertical: SPACING.xxl,
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
marginBottom: SPACING.lg,
...SHADOWS.sm,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: COLORS.gray100,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING.md,
},
emptyIconText: {
fontSize: 40,
},
emptyTitle: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
emptySubtitle: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
textAlign: 'center',
},
statsGrid: {
flexDirection: 'row',
gap: SPACING.sm,
},
statCard: {
flex: 1,
},
quickActions: {
flexDirection: 'row',
justifyContent: 'space-around',
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
...SHADOWS.sm,
},
quickAction: {
alignItems: 'center',
},
quickActionIcon: {
width: 56,
height: 56,
borderRadius: BORDER_RADIUS.lg,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING.xs,
},
quickActionEmoji: {
fontSize: 24,
},
quickActionText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: COLORS.overlayLight,
alignItems: 'center',
justifyContent: 'center',
},
});
export default HomeScreen;

View File

@@ -0,0 +1,376 @@
/**
* Pantalla Login - Autenticación por teléfono
*/
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
KeyboardAvoidingView,
Platform,
Alert,
TextInput as RNTextInput,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { Button, Input, LoadingOverlay } from '../components';
import { useAuth } from '../hooks';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
FONT_WEIGHT,
} from '../utils/theme';
import { isValidPhone, formatPhone } from '../utils/helpers';
import type { AuthStackParamList } from '../types';
type LoginNavigationProp = StackNavigationProp<AuthStackParamList, 'Login'>;
export const LoginScreen: React.FC = () => {
const navigation = useNavigation<LoginNavigationProp>();
const { login, isLoading } = useAuth();
const [telefono, setTelefono] = useState('');
const [error, setError] = useState<string | null>(null);
const handlePhoneChange = (text: string) => {
// Solo permitir números
const cleaned = text.replace(/\D/g, '');
setTelefono(cleaned);
setError(null);
};
const handleContinue = async () => {
if (!telefono) {
setError('Ingresa tu número de teléfono');
return;
}
if (!isValidPhone(telefono)) {
setError('Número de teléfono inválido');
return;
}
const result = await login(telefono);
if (result.success) {
navigation.navigate('VerifyCode', { telefono });
} else {
Alert.alert('Error', result.error || 'No se pudo enviar el código');
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.content}>
{/* Logo/Header */}
<View style={styles.header}>
<View style={styles.logoContainer}>
<Text style={styles.logoText}>A</Text>
</View>
<Text style={styles.title}>Adan Conductor</Text>
<Text style={styles.subtitle}>
Ingresa tu número de teléfono para continuar
</Text>
</View>
{/* Formulario */}
<View style={styles.form}>
<Input
label="Número de teléfono"
placeholder="10 dígitos"
value={formatPhone(telefono)}
onChangeText={handlePhoneChange}
keyboardType="phone-pad"
maxLength={14}
error={error || undefined}
size="lg"
leftIcon={
<Text style={styles.countryCode}>+52</Text>
}
/>
<Button
title="Continuar"
onPress={handleContinue}
size="xl"
fullWidth
loading={isLoading}
disabled={telefono.length < 10}
/>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>
Al continuar aceptas los{' '}
<Text style={styles.linkText}>Términos de Servicio</Text>
{' '}y la{' '}
<Text style={styles.linkText}>Política de Privacidad</Text>
</Text>
</View>
</View>
</KeyboardAvoidingView>
<LoadingOverlay visible={isLoading} message="Enviando código..." />
</SafeAreaView>
);
};
/**
* Pantalla VerifyCode - Verificación de código SMS
*/
interface VerifyCodeProps {
route: {
params: {
telefono: string;
};
};
}
export const VerifyCodeScreen: React.FC<VerifyCodeProps> = ({ route }) => {
const { telefono } = route.params;
const { verify, login, isLoading } = useAuth();
const [codigo, setCodigo] = useState(['', '', '', '', '', '']);
const [error, setError] = useState<string | null>(null);
const inputRefs = useRef<Array<RNTextInput | null>>([]);
const handleCodeChange = (text: string, index: number) => {
if (text.length > 1) {
// Pegar código completo
const pastedCode = text.replace(/\D/g, '').slice(0, 6).split('');
const newCodigo = [...codigo];
pastedCode.forEach((digit, i) => {
if (i < 6) newCodigo[i] = digit;
});
setCodigo(newCodigo);
if (pastedCode.length === 6) {
handleVerify(newCodigo.join(''));
}
return;
}
const newCodigo = [...codigo];
newCodigo[index] = text.replace(/\D/g, '');
setCodigo(newCodigo);
setError(null);
// Mover al siguiente input
if (text && index < 5) {
inputRefs.current[index + 1]?.focus();
}
// Verificar automáticamente si está completo
const fullCode = newCodigo.join('');
if (fullCode.length === 6) {
handleVerify(fullCode);
}
};
const handleKeyPress = (key: string, index: number) => {
if (key === 'Backspace' && !codigo[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handleVerify = async (code: string) => {
const result = await verify(telefono, code);
if (!result.success) {
setError(result.error || 'Código inválido');
setCodigo(['', '', '', '', '', '']);
inputRefs.current[0]?.focus();
}
};
const handleResend = async () => {
setError(null);
setCodigo(['', '', '', '', '', '']);
const result = await login(telefono);
if (result.success) {
Alert.alert('Código reenviado', 'Se ha enviado un nuevo código a tu teléfono');
} else {
Alert.alert('Error', result.error || 'No se pudo reenviar el código');
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Verificar código</Text>
<Text style={styles.subtitle}>
Ingresa el código de 6 dígitos enviado a{'\n'}
<Text style={styles.phoneHighlight}>{formatPhone(telefono)}</Text>
</Text>
</View>
{/* Código */}
<View style={styles.codeContainer}>
{codigo.map((digit, index) => (
<RNTextInput
key={index}
ref={(ref) => (inputRefs.current[index] = ref)}
style={[
styles.codeInput,
digit && styles.codeInputFilled,
error && styles.codeInputError,
]}
value={digit}
onChangeText={(text) => handleCodeChange(text, index)}
onKeyPress={({ nativeEvent }) =>
handleKeyPress(nativeEvent.key, index)
}
keyboardType="number-pad"
maxLength={1}
selectTextOnFocus
/>
))}
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
{/* Reenviar */}
<View style={styles.resendContainer}>
<Text style={styles.resendText}>No recibiste el código? </Text>
<Button
title="Reenviar"
onPress={handleResend}
variant="ghost"
size="sm"
/>
</View>
</View>
</KeyboardAvoidingView>
<LoadingOverlay visible={isLoading} message="Verificando..." />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
content: {
flex: 1,
padding: SPACING.xl,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: SPACING.xxl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: BORDER_RADIUS.xl,
backgroundColor: COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING.lg,
},
logoText: {
fontSize: 40,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
title: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
subtitle: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
textAlign: 'center',
lineHeight: 24,
},
phoneHighlight: {
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.primary,
},
form: {
marginBottom: SPACING.xl,
},
countryCode: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textPrimary,
},
footer: {
alignItems: 'center',
},
footerText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
textAlign: 'center',
lineHeight: 20,
},
linkText: {
color: COLORS.primary,
},
codeContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: SPACING.lg,
gap: SPACING.sm,
},
codeInput: {
width: 48,
height: 56,
borderWidth: 2,
borderColor: COLORS.border,
borderRadius: BORDER_RADIUS.lg,
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
textAlign: 'center',
color: COLORS.textPrimary,
backgroundColor: COLORS.white,
},
codeInputFilled: {
borderColor: COLORS.primary,
backgroundColor: `${COLORS.primary}10`,
},
codeInputError: {
borderColor: COLORS.danger,
},
errorText: {
fontSize: FONT_SIZE.sm,
color: COLORS.danger,
textAlign: 'center',
marginBottom: SPACING.md,
},
resendContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
resendText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
},
});
export default LoginScreen;

View File

@@ -0,0 +1,430 @@
/**
* Pantalla Mensajes - Chat con administración
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
FlatList,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
RefreshControl,
} from 'react-native';
import { mensajesApi } from '../services/api';
import { useAuth } from '../hooks';
import { LoadingSpinner } from '../components';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import { formatDateTime, timeAgo } from '../utils/helpers';
import type { Mensaje, TipoMensaje } from '../types';
export const MensajesScreen: React.FC = () => {
const { conductor } = useAuth();
const flatListRef = useRef<FlatList>(null);
const [mensajes, setMensajes] = useState<Mensaje[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isSending, setIsSending] = useState(false);
const [nuevoMensaje, setNuevoMensaje] = useState('');
const [pagina, setPagina] = useState(1);
const [hasMore, setHasMore] = useState(true);
// Cargar mensajes
const cargarMensajes = useCallback(async (page: number, refresh = false) => {
try {
const response = await mensajesApi.getMensajes(page);
if (response.success && response.data) {
const nuevosMensajes = response.data.items;
if (refresh) {
setMensajes(nuevosMensajes);
} else {
setMensajes((prev) => [...prev, ...nuevosMensajes]);
}
setHasMore(page < response.data.totalPaginas);
}
} catch (error) {
console.error('Error cargando mensajes:', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
// Cargar inicial
useEffect(() => {
cargarMensajes(1, true);
}, [cargarMensajes]);
// Refrescar
const onRefresh = useCallback(() => {
setIsRefreshing(true);
setPagina(1);
cargarMensajes(1, true);
}, [cargarMensajes]);
// Cargar más
const onEndReached = useCallback(() => {
if (!isLoading && hasMore) {
const nextPage = pagina + 1;
setPagina(nextPage);
cargarMensajes(nextPage);
}
}, [isLoading, hasMore, pagina, cargarMensajes]);
// Enviar mensaje
const enviarMensaje = async () => {
if (!nuevoMensaje.trim() || isSending) return;
setIsSending(true);
try {
const response = await mensajesApi.enviarMensaje(nuevoMensaje.trim());
if (response.success && response.data) {
setMensajes((prev) => [response.data!, ...prev]);
setNuevoMensaje('');
// Scroll al final
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}
} catch (error) {
console.error('Error enviando mensaje:', error);
} finally {
setIsSending(false);
}
};
// Marcar como leído
const marcarLeido = async (mensajeId: string) => {
try {
await mensajesApi.marcarLeido(mensajeId);
setMensajes((prev) =>
prev.map((m) => (m.id === mensajeId ? { ...m, leido: true } : m))
);
} catch (error) {
console.error('Error marcando leído:', error);
}
};
const getTipoMensajeStyle = (tipo: TipoMensaje) => {
switch (tipo) {
case 'alerta':
return { backgroundColor: `${COLORS.warning}15`, borderColor: COLORS.warning };
case 'emergencia':
return { backgroundColor: `${COLORS.danger}15`, borderColor: COLORS.danger };
case 'instruccion':
return { backgroundColor: `${COLORS.info}15`, borderColor: COLORS.info };
default:
return {};
}
};
const renderMensaje = ({ item }: { item: Mensaje }) => {
const esPropio = item.remitente === 'conductor';
const tipoStyle = getTipoMensajeStyle(item.tipo);
return (
<TouchableOpacity
style={[
styles.mensajeContainer,
esPropio ? styles.mensajePropio : styles.mensajeRecibido,
]}
onPress={() => !item.leido && !esPropio && marcarLeido(item.id)}
activeOpacity={0.8}
>
<View
style={[
styles.mensajeBubble,
esPropio ? styles.bubblePropio : styles.bubbleRecibido,
!esPropio && tipoStyle,
]}
>
{/* Badge de tipo para mensajes especiales */}
{!esPropio && item.tipo !== 'texto' && (
<View
style={[
styles.tipoBadge,
{ backgroundColor: tipoStyle.borderColor },
]}
>
<Text style={styles.tipoBadgeText}>
{item.tipo.toUpperCase()}
</Text>
</View>
)}
<Text
style={[
styles.mensajeText,
esPropio ? styles.mensajeTextPropio : styles.mensajeTextRecibido,
]}
>
{item.contenido}
</Text>
<View style={styles.mensajeFooter}>
<Text
style={[
styles.mensajeTime,
esPropio ? styles.timePropio : styles.timeRecibido,
]}
>
{timeAgo(item.fechaEnvio)}
</Text>
{esPropio && (
<Text style={styles.checkmark}>
{item.leido ? '✓✓' : '✓'}
</Text>
)}
{!esPropio && !item.leido && (
<View style={styles.unreadDot} />
)}
</View>
</View>
</TouchableOpacity>
);
};
if (isLoading && mensajes.length === 0) {
return (
<SafeAreaView style={styles.container}>
<LoadingSpinner message="Cargando mensajes..." />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardView}
keyboardVerticalOffset={90}
>
{/* Lista de mensajes */}
<FlatList
ref={flatListRef}
data={mensajes}
renderItem={renderMensaje}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
inverted
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>💬</Text>
<Text style={styles.emptyText}>No hay mensajes</Text>
<Text style={styles.emptySubtext}>
Envía un mensaje para comunicarte con administración
</Text>
</View>
}
ListFooterComponent={
isLoading && mensajes.length > 0 ? (
<View style={styles.loadingMore}>
<LoadingSpinner size="small" />
</View>
) : null
}
/>
{/* Input de mensaje */}
<View style={styles.inputContainer}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.textInput}
placeholder="Escribe un mensaje..."
placeholderTextColor={COLORS.textTertiary}
value={nuevoMensaje}
onChangeText={setNuevoMensaje}
multiline
maxLength={1000}
/>
</View>
<TouchableOpacity
style={[
styles.sendButton,
(!nuevoMensaje.trim() || isSending) && styles.sendButtonDisabled,
]}
onPress={enviarMensaje}
disabled={!nuevoMensaje.trim() || isSending}
>
<Text style={styles.sendButtonText}>
{isSending ? '...' : '➤'}
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
listContent: {
padding: SPACING.md,
paddingTop: SPACING.sm,
},
mensajeContainer: {
marginBottom: SPACING.sm,
maxWidth: '85%',
},
mensajePropio: {
alignSelf: 'flex-end',
},
mensajeRecibido: {
alignSelf: 'flex-start',
},
mensajeBubble: {
padding: SPACING.md,
borderRadius: BORDER_RADIUS.xl,
},
bubblePropio: {
backgroundColor: COLORS.primary,
borderBottomRightRadius: BORDER_RADIUS.sm,
},
bubbleRecibido: {
backgroundColor: COLORS.white,
borderBottomLeftRadius: BORDER_RADIUS.sm,
borderWidth: 1,
borderColor: COLORS.border,
},
tipoBadge: {
alignSelf: 'flex-start',
paddingHorizontal: SPACING.xs,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.sm,
marginBottom: SPACING.xs,
},
tipoBadgeText: {
fontSize: 10,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
mensajeText: {
fontSize: FONT_SIZE.md,
lineHeight: 22,
},
mensajeTextPropio: {
color: COLORS.white,
},
mensajeTextRecibido: {
color: COLORS.textPrimary,
},
mensajeFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: SPACING.xs,
gap: SPACING.xs,
},
mensajeTime: {
fontSize: FONT_SIZE.xs,
},
timePropio: {
color: 'rgba(255,255,255,0.7)',
},
timeRecibido: {
color: COLORS.textTertiary,
},
checkmark: {
fontSize: FONT_SIZE.xs,
color: 'rgba(255,255,255,0.7)',
},
unreadDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: COLORS.primary,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
padding: SPACING.md,
backgroundColor: COLORS.white,
borderTopWidth: 1,
borderTopColor: COLORS.border,
gap: SPACING.sm,
},
inputWrapper: {
flex: 1,
backgroundColor: COLORS.gray100,
borderRadius: BORDER_RADIUS.xl,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
maxHeight: 120,
},
textInput: {
fontSize: FONT_SIZE.md,
color: COLORS.textPrimary,
maxHeight: 100,
},
sendButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
...SHADOWS.sm,
},
sendButtonDisabled: {
backgroundColor: COLORS.gray300,
},
sendButtonText: {
fontSize: 20,
color: COLORS.white,
},
emptyState: {
alignItems: 'center',
paddingVertical: SPACING.xxl,
},
emptyEmoji: {
fontSize: 64,
marginBottom: SPACING.md,
},
emptyText: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
emptySubtext: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
textAlign: 'center',
paddingHorizontal: SPACING.xl,
},
loadingMore: {
paddingVertical: SPACING.md,
},
});
export default MensajesScreen;

View File

@@ -0,0 +1,563 @@
/**
* Pantalla Perfil - Información del conductor y estadísticas
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
Alert,
RefreshControl,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Button, StatCard, LoadingSpinner } from '../components';
import { useAuth } from '../hooks';
import { estadisticasApi } from '../services/api';
import { storage, STORAGE_KEYS } from '../services/storage';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import {
formatDistance,
formatDuration,
getInitials,
} from '../utils/helpers';
import type { EstadisticasConductor, ConfiguracionApp } from '../types';
const CONFIG_DEFAULT: ConfiguracionApp = {
intervaloUbicacion: 10,
tiempoSincronizacion: 30,
modoOscuro: false,
notificacionesSonido: true,
notificacionesVibracion: true,
calidadVideo: 'media',
autoDetectarParadas: true,
velocidadParada: 5,
};
export const PerfilScreen: React.FC = () => {
const navigation = useNavigation();
const { conductor, signOut } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [estadisticas, setEstadisticas] = useState<EstadisticasConductor | null>(
null
);
const [configuracion, setConfiguracion] =
useState<ConfiguracionApp>(CONFIG_DEFAULT);
const [showConfig, setShowConfig] = useState(false);
// Cargar estadísticas
const cargarEstadisticas = useCallback(async () => {
try {
const response = await estadisticasApi.getEstadisticasConductor();
if (response.success && response.data) {
setEstadisticas(response.data);
}
} catch (error) {
console.error('Error cargando estadísticas:', error);
} finally {
setIsLoading(false);
setRefreshing(false);
}
}, []);
// Cargar configuración
const cargarConfiguracion = useCallback(async () => {
const config = await storage.get<ConfiguracionApp>(STORAGE_KEYS.CONFIGURACION);
if (config) {
setConfiguracion(config);
}
}, []);
useEffect(() => {
cargarEstadisticas();
cargarConfiguracion();
}, [cargarEstadisticas, cargarConfiguracion]);
const onRefresh = () => {
setRefreshing(true);
cargarEstadisticas();
};
// Guardar configuración
const guardarConfiguracion = async (updates: Partial<ConfiguracionApp>) => {
const newConfig = { ...configuracion, ...updates };
setConfiguracion(newConfig);
await storage.set(STORAGE_KEYS.CONFIGURACION, newConfig);
};
// Cerrar sesión
const handleLogout = () => {
Alert.alert('Cerrar sesión', 'Estas seguro de que quieres cerrar sesión?', [
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Cerrar sesión',
style: 'destructive',
onPress: async () => {
await signOut();
},
},
]);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
showsVerticalScrollIndicator={false}
>
{/* Header con info del conductor */}
<View style={[styles.profileCard, SHADOWS.md]}>
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{conductor ? getInitials(`${conductor.nombre} ${conductor.apellido}`) : '?'}
</Text>
</View>
{conductor?.calificacion && (
<View style={styles.ratingBadge}>
<Text style={styles.ratingText}>
{conductor.calificacion.toFixed(1)}
</Text>
</View>
)}
</View>
<Text style={styles.conductorName}>
{conductor?.nombre} {conductor?.apellido}
</Text>
<Text style={styles.conductorPhone}>{conductor?.telefono}</Text>
{conductor?.vehiculoAsignado && (
<View style={styles.vehiculoInfo}>
<Text style={styles.vehiculoLabel}>Vehículo asignado:</Text>
<Text style={styles.vehiculoValue}>
{conductor.vehiculoAsignado.marca}{' '}
{conductor.vehiculoAsignado.modelo} {' '}
{conductor.vehiculoAsignado.placa}
</Text>
</View>
)}
</View>
{/* Estadísticas */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Estadísticas Generales</Text>
{isLoading ? (
<LoadingSpinner message="Cargando estadísticas..." />
) : estadisticas ? (
<View style={styles.statsGrid}>
<StatCard
title="Viajes totales"
value={estadisticas.totalViajes}
color={COLORS.primary}
size="sm"
style={styles.statCard}
/>
<StatCard
title="Distancia total"
value={formatDistance(estadisticas.distanciaTotal)}
color={COLORS.success}
size="sm"
style={styles.statCard}
/>
<StatCard
title="Horas conducidas"
value={formatDuration(estadisticas.horasConduccion * 60)}
color={COLORS.info}
size="sm"
style={styles.statCard}
/>
<StatCard
title="Calificación"
value={estadisticas.calificacionPromedio.toFixed(1)}
subtitle="promedio"
color={COLORS.warning}
size="sm"
style={styles.statCard}
/>
</View>
) : (
<Text style={styles.noDataText}>
No hay estadísticas disponibles
</Text>
)}
</View>
{/* Este mes */}
{estadisticas && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Este Mes</Text>
<View style={styles.monthStats}>
<View style={styles.monthStatItem}>
<Text style={styles.monthStatValue}>
{estadisticas.viajesEsteMes}
</Text>
<Text style={styles.monthStatLabel}>viajes</Text>
</View>
<View style={styles.monthStatDivider} />
<View style={styles.monthStatItem}>
<Text style={styles.monthStatValue}>
{formatDistance(estadisticas.distanciaEsteMes)}
</Text>
<Text style={styles.monthStatLabel}>recorridos</Text>
</View>
</View>
</View>
)}
{/* Configuración */}
<View style={styles.section}>
<TouchableOpacity
style={styles.sectionHeader}
onPress={() => setShowConfig(!showConfig)}
>
<Text style={styles.sectionTitle}>Configuración</Text>
<Text style={styles.chevron}>{showConfig ? '▲' : '▼'}</Text>
</TouchableOpacity>
{showConfig && (
<View style={[styles.configCard, SHADOWS.sm]}>
<View style={styles.configItem}>
<View>
<Text style={styles.configLabel}>Notificaciones con sonido</Text>
<Text style={styles.configHint}>
Reproducir sonido al recibir notificaciones
</Text>
</View>
<Switch
value={configuracion.notificacionesSonido}
onValueChange={(value) =>
guardarConfiguracion({ notificacionesSonido: value })
}
trackColor={{ false: COLORS.gray300, true: COLORS.primaryLight }}
thumbColor={
configuracion.notificacionesSonido
? COLORS.primary
: COLORS.gray400
}
/>
</View>
<View style={styles.configDivider} />
<View style={styles.configItem}>
<View>
<Text style={styles.configLabel}>Vibración</Text>
<Text style={styles.configHint}>
Vibrar al recibir notificaciones
</Text>
</View>
<Switch
value={configuracion.notificacionesVibracion}
onValueChange={(value) =>
guardarConfiguracion({ notificacionesVibracion: value })
}
trackColor={{ false: COLORS.gray300, true: COLORS.primaryLight }}
thumbColor={
configuracion.notificacionesVibracion
? COLORS.primary
: COLORS.gray400
}
/>
</View>
<View style={styles.configDivider} />
<View style={styles.configItem}>
<View>
<Text style={styles.configLabel}>Detectar paradas automáticamente</Text>
<Text style={styles.configHint}>
Detectar cuando te detienes por más de 2 minutos
</Text>
</View>
<Switch
value={configuracion.autoDetectarParadas}
onValueChange={(value) =>
guardarConfiguracion({ autoDetectarParadas: value })
}
trackColor={{ false: COLORS.gray300, true: COLORS.primaryLight }}
thumbColor={
configuracion.autoDetectarParadas
? COLORS.primary
: COLORS.gray400
}
/>
</View>
</View>
)}
</View>
{/* Opciones */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Cuenta</Text>
<View style={[styles.menuCard, SHADOWS.sm]}>
<TouchableOpacity
style={styles.menuItem}
onPress={() => Alert.alert('Próximamente', 'Función en desarrollo')}
>
<Text style={styles.menuIcon}>📄</Text>
<Text style={styles.menuText}>Términos y condiciones</Text>
<Text style={styles.menuChevron}></Text>
</TouchableOpacity>
<View style={styles.menuDivider} />
<TouchableOpacity
style={styles.menuItem}
onPress={() => Alert.alert('Próximamente', 'Función en desarrollo')}
>
<Text style={styles.menuIcon}>🔒</Text>
<Text style={styles.menuText}>Política de privacidad</Text>
<Text style={styles.menuChevron}></Text>
</TouchableOpacity>
<View style={styles.menuDivider} />
<TouchableOpacity
style={styles.menuItem}
onPress={() => Alert.alert('Próximamente', 'Función en desarrollo')}
>
<Text style={styles.menuIcon}></Text>
<Text style={styles.menuText}>Ayuda y soporte</Text>
<Text style={styles.menuChevron}></Text>
</TouchableOpacity>
</View>
</View>
{/* Cerrar sesión */}
<Button
title="Cerrar sesión"
onPress={handleLogout}
variant="danger"
size="lg"
fullWidth
style={styles.logoutButton}
/>
{/* Versión */}
<Text style={styles.versionText}>Adan Conductor v1.0.0</Text>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: SPACING.md,
paddingBottom: SPACING.xxl,
},
profileCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xxl,
padding: SPACING.lg,
alignItems: 'center',
marginBottom: SPACING.lg,
},
avatarContainer: {
position: 'relative',
marginBottom: SPACING.md,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 32,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
ratingBadge: {
position: 'absolute',
bottom: -4,
right: -4,
backgroundColor: COLORS.warning,
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.full,
},
ratingText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
conductorName: {
fontSize: FONT_SIZE.xl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
conductorPhone: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
marginBottom: SPACING.md,
},
vehiculoInfo: {
backgroundColor: COLORS.gray100,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.sm,
width: '100%',
alignItems: 'center',
},
vehiculoLabel: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
vehiculoValue: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.medium,
color: COLORS.textPrimary,
},
section: {
marginBottom: SPACING.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
chevron: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING.sm,
},
statCard: {
flex: 1,
minWidth: '45%',
},
noDataText: {
fontSize: FONT_SIZE.md,
color: COLORS.textTertiary,
textAlign: 'center',
padding: SPACING.lg,
},
monthStats: {
flexDirection: 'row',
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
...SHADOWS.sm,
},
monthStatItem: {
flex: 1,
alignItems: 'center',
},
monthStatValue: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.primary,
},
monthStatLabel: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
},
monthStatDivider: {
width: 1,
backgroundColor: COLORS.border,
marginHorizontal: SPACING.md,
},
configCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
},
configItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
},
configLabel: {
fontSize: FONT_SIZE.md,
color: COLORS.textPrimary,
marginBottom: 2,
},
configHint: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
maxWidth: 250,
},
configDivider: {
height: 1,
backgroundColor: COLORS.border,
marginVertical: SPACING.xs,
},
menuCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
padding: SPACING.md,
},
menuIcon: {
fontSize: 20,
marginRight: SPACING.md,
},
menuText: {
flex: 1,
fontSize: FONT_SIZE.md,
color: COLORS.textPrimary,
},
menuChevron: {
fontSize: FONT_SIZE.xl,
color: COLORS.textTertiary,
},
menuDivider: {
height: 1,
backgroundColor: COLORS.border,
marginLeft: 52,
},
logoutButton: {
marginTop: SPACING.md,
},
versionText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
textAlign: 'center',
marginTop: SPACING.lg,
},
});
export default PerfilScreen;

View File

@@ -0,0 +1,346 @@
/**
* Pantalla RegistrarParada - Registro de paradas programadas y no programadas
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
Alert,
TextInput,
} from 'react-native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { Button, Input, LoadingOverlay } from '../components';
import { useViaje } from '../hooks';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import { getParadaLabel } from '../utils/helpers';
import type { ViajeStackParamList, TipoParada } from '../types';
type RegistrarParadaNavigationProp = StackNavigationProp<
ViajeStackParamList,
'RegistrarParada'
>;
type RegistrarParadaRouteProp = RouteProp<ViajeStackParamList, 'RegistrarParada'>;
const TIPOS_PARADA: Array<{ tipo: TipoParada; emoji: string; descripcion: string }> = [
{ tipo: 'carga', emoji: '📦', descripcion: 'Carga de mercancía' },
{ tipo: 'descarga', emoji: '📤', descripcion: 'Descarga de mercancía' },
{ tipo: 'descanso', emoji: '☕', descripcion: 'Descanso o comida' },
{ tipo: 'combustible', emoji: '⛽', descripcion: 'Carga de combustible' },
{ tipo: 'mecanico', emoji: '🔧', descripcion: 'Servicio mecánico' },
{ tipo: 'otro', emoji: '📍', descripcion: 'Otra parada' },
];
export const RegistrarParadaScreen: React.FC = () => {
const navigation = useNavigation<RegistrarParadaNavigationProp>();
const route = useRoute<RegistrarParadaRouteProp>();
const { paradaId } = route.params || {};
const { paradaActual, isLoading, registrarParada, salirDeParada } = useViaje();
const [tipoSeleccionado, setTipoSeleccionado] = useState<TipoParada | null>(
paradaActual?.tipo || null
);
const [notas, setNotas] = useState('');
const isParadaProgramada = !!paradaId && !!paradaActual;
const handleGuardar = async () => {
if (isParadaProgramada) {
// Salir de parada programada con notas
const result = await salirDeParada(paradaId!, notas || undefined);
if (result.success) {
navigation.goBack();
} else {
Alert.alert('Error', result.error || 'No se pudo completar la parada');
}
} else {
// Registrar parada no programada
if (!tipoSeleccionado) {
Alert.alert('Error', 'Selecciona el tipo de parada');
return;
}
const result = await registrarParada({
tipo: tipoSeleccionado,
notas: notas || undefined,
});
if (result.success) {
Alert.alert('Parada registrada', 'La parada se registro correctamente', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} else {
Alert.alert('Error', result.error || 'No se pudo registrar la parada');
}
}
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{isParadaProgramada ? 'Completar Parada' : 'Nueva Parada'}
</Text>
<Text style={styles.subtitle}>
{isParadaProgramada
? 'Agrega notas opcionales antes de salir'
: 'Registra una parada no programada'}
</Text>
</View>
{/* Info de parada programada */}
{isParadaProgramada && paradaActual && (
<View style={[styles.paradaInfo, SHADOWS.sm]}>
<View style={styles.paradaHeader}>
<View
style={[
styles.paradaTipo,
{ backgroundColor: `${COLORS.primary}15` },
]}
>
<Text style={[styles.paradaTipoText, { color: COLORS.primary }]}>
{getParadaLabel(paradaActual.tipo)}
</Text>
</View>
</View>
<Text style={styles.paradaNombre}>{paradaActual.punto.nombre}</Text>
<Text style={styles.paradaDireccion}>
{paradaActual.punto.direccion}
</Text>
</View>
)}
{/* Selector de tipo (solo para paradas no programadas) */}
{!isParadaProgramada && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Tipo de parada</Text>
<View style={styles.tiposGrid}>
{TIPOS_PARADA.map(({ tipo, emoji, descripcion }) => (
<TouchableOpacity
key={tipo}
style={[
styles.tipoCard,
tipoSeleccionado === tipo && styles.tipoCardSelected,
SHADOWS.sm,
]}
onPress={() => setTipoSeleccionado(tipo)}
>
<Text style={styles.tipoEmoji}>{emoji}</Text>
<Text
style={[
styles.tipoNombre,
tipoSeleccionado === tipo && styles.tipoNombreSelected,
]}
>
{getParadaLabel(tipo)}
</Text>
<Text style={styles.tipoDescripcion}>{descripcion}</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
{/* Notas */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Notas (opcional)</Text>
<View style={[styles.textAreaContainer, SHADOWS.sm]}>
<TextInput
style={styles.textArea}
placeholder="Agrega detalles sobre esta parada..."
placeholderTextColor={COLORS.textTertiary}
multiline
numberOfLines={4}
value={notas}
onChangeText={setNotas}
textAlignVertical="top"
/>
</View>
</View>
{/* Fotos (placeholder) */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Fotos (opcional)</Text>
<TouchableOpacity
style={[styles.photoButton, SHADOWS.sm]}
onPress={() => Alert.alert('Proximamente', 'Función de fotos en desarrollo')}
>
<Text style={styles.photoButtonEmoji}>📷</Text>
<Text style={styles.photoButtonText}>Agregar foto</Text>
</TouchableOpacity>
</View>
</ScrollView>
{/* Botón guardar */}
<View style={styles.footer}>
<Button
title={isParadaProgramada ? 'Salir de parada' : 'Registrar parada'}
onPress={handleGuardar}
variant={isParadaProgramada ? 'success' : 'primary'}
size="xl"
fullWidth
disabled={!isParadaProgramada && !tipoSeleccionado}
loading={isLoading}
/>
</View>
<LoadingOverlay visible={isLoading} />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: SPACING.md,
paddingBottom: SPACING.xxl,
},
header: {
marginBottom: SPACING.lg,
},
title: {
fontSize: FONT_SIZE.xxl,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
subtitle: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
paradaInfo: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
marginBottom: SPACING.lg,
},
paradaHeader: {
marginBottom: SPACING.sm,
},
paradaTipo: {
alignSelf: 'flex-start',
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
paradaTipoText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.semibold,
},
paradaNombre: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
paradaDireccion: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
},
section: {
marginBottom: SPACING.lg,
},
sectionTitle: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.sm,
},
tiposGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING.sm,
},
tipoCard: {
width: '48%',
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
alignItems: 'center',
borderWidth: 2,
borderColor: 'transparent',
},
tipoCardSelected: {
borderColor: COLORS.primary,
backgroundColor: `${COLORS.primary}05`,
},
tipoEmoji: {
fontSize: 32,
marginBottom: SPACING.xs,
},
tipoNombre: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: 2,
},
tipoNombreSelected: {
color: COLORS.primary,
},
tipoDescripcion: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
textAlign: 'center',
},
textAreaContainer: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
borderWidth: 1,
borderColor: COLORS.border,
},
textArea: {
padding: SPACING.md,
fontSize: FONT_SIZE.md,
color: COLORS.textPrimary,
minHeight: 120,
},
photoButton: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
alignItems: 'center',
borderWidth: 2,
borderColor: COLORS.border,
borderStyle: 'dashed',
},
photoButtonEmoji: {
fontSize: 40,
marginBottom: SPACING.sm,
},
photoButtonText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
},
footer: {
padding: SPACING.md,
backgroundColor: COLORS.white,
borderTopWidth: 1,
borderTopColor: COLORS.border,
},
});
export default RegistrarParadaScreen;

View File

@@ -0,0 +1,634 @@
/**
* Pantalla ViajeActivo - Viaje en curso con mapa y controles
*/
import React, { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
SafeAreaView,
ScrollView,
TouchableOpacity,
Alert,
Dimensions,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { Button, MapView, LoadingOverlay } from '../components';
import { useViaje, useLocation } from '../hooks';
import {
COLORS,
SPACING,
FONT_SIZE,
BORDER_RADIUS,
SHADOWS,
FONT_WEIGHT,
} from '../utils/theme';
import {
formatDistance,
formatDuration,
formatTime,
getParadaLabel,
} from '../utils/helpers';
import type { ViajeStackParamList, Parada } from '../types';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
type ViajeNavigationProp = StackNavigationProp<ViajeStackParamList, 'ViajeActivo'>;
export const ViajeActivoScreen: React.FC = () => {
const navigation = useNavigation<ViajeNavigationProp>();
const {
viaje,
paradaActual,
isLoading,
progreso,
distanciaRestante,
tiempoEstimadoRestante,
distanciaAParada,
pausar,
reanudar,
finalizar,
llegarAParada,
salirDeParada,
} = useViaje();
const { ubicacion, isTracking } = useLocation();
const [showingParadas, setShowingParadas] = useState(false);
if (!viaje) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No hay viaje activo</Text>
</View>
</SafeAreaView>
);
}
const handlePausarReanudar = async () => {
if (viaje.estado === 'en_curso') {
Alert.alert('Pausar Viaje', 'Quieres pausar el viaje?', [
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Pausar',
onPress: async () => {
const result = await pausar('Pausa manual');
if (!result.success) {
Alert.alert('Error', result.error || 'No se pudo pausar');
}
},
},
]);
} else if (viaje.estado === 'pausado') {
const result = await reanudar();
if (!result.success) {
Alert.alert('Error', result.error || 'No se pudo reanudar');
}
}
};
const handleFinalizar = () => {
Alert.alert(
'Finalizar Viaje',
'Estas seguro de que quieres finalizar este viaje?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Finalizar',
style: 'destructive',
onPress: async () => {
const result = await finalizar();
if (result.success) {
navigation.goBack();
} else {
Alert.alert('Error', result.error || 'No se pudo finalizar');
}
},
},
]
);
};
const handleLlegadaParada = async () => {
if (!paradaActual) return;
const result = await llegarAParada(paradaActual.id);
if (result.success) {
navigation.navigate('RegistrarParada', { paradaId: paradaActual.id });
} else {
Alert.alert('Error', result.error || 'No se pudo registrar llegada');
}
};
const handleSalidaParada = async () => {
if (!paradaActual) return;
Alert.alert('Salir de parada', 'Confirmas la salida de esta parada?', [
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Confirmar',
onPress: async () => {
const result = await salirDeParada(paradaActual.id);
if (!result.success) {
Alert.alert('Error', result.error || 'No se pudo registrar salida');
}
},
},
]);
};
const handleEmergencia = () => {
navigation.navigate('Emergencia');
};
const handleCombustible = () => {
navigation.navigate('Combustible');
};
const handleRegistrarParada = () => {
navigation.navigate('RegistrarParada', {});
};
const isPaused = viaje.estado === 'pausado';
const isEnParada = paradaActual?.horaLlegada && !paradaActual?.horaSalida;
return (
<SafeAreaView style={styles.container}>
{/* Mapa */}
<View style={styles.mapContainer}>
<MapView
ubicacionActual={ubicacion}
origen={viaje.origen}
destino={viaje.destino}
paradas={viaje.paradas}
showUserLocation
followUser={!isPaused}
height={SCREEN_HEIGHT * 0.4}
/>
{/* Overlay de estado */}
<View style={styles.mapOverlay}>
<View style={styles.trackingIndicator}>
<View
style={[
styles.trackingDot,
{ backgroundColor: isTracking ? COLORS.success : COLORS.danger },
]}
/>
<Text style={styles.trackingText}>
{isTracking ? 'GPS activo' : 'GPS inactivo'}
</Text>
</View>
{isPaused && (
<View style={styles.pausedBadge}>
<Text style={styles.pausedText}>PAUSADO</Text>
</View>
)}
</View>
{/* Boton SOS */}
<TouchableOpacity style={styles.sosButton} onPress={handleEmergencia}>
<Text style={styles.sosText}>SOS</Text>
</TouchableOpacity>
</View>
{/* Panel inferior */}
<ScrollView style={styles.panel} contentContainerStyle={styles.panelContent}>
{/* Progreso */}
<View style={styles.progressSection}>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progreso}%` }]} />
</View>
<View style={styles.progressInfo}>
<View style={styles.progressItem}>
<Text style={styles.progressValue}>
{formatDistance(distanciaRestante)}
</Text>
<Text style={styles.progressLabel}>restantes</Text>
</View>
<View style={styles.progressItem}>
<Text style={styles.progressValue}>
{formatDuration(tiempoEstimadoRestante)}
</Text>
<Text style={styles.progressLabel}>tiempo est.</Text>
</View>
<View style={styles.progressItem}>
<Text style={styles.progressValue}>{Math.round(progreso)}%</Text>
<Text style={styles.progressLabel}>completado</Text>
</View>
</View>
</View>
{/* Próxima parada */}
{paradaActual && (
<View style={styles.paradaSection}>
<Text style={styles.sectionTitle}>
{isEnParada ? 'En parada' : 'Próxima parada'}
</Text>
<View style={[styles.paradaCard, SHADOWS.md]}>
<View style={styles.paradaHeader}>
<View
style={[
styles.paradaTipo,
{ backgroundColor: `${COLORS.primary}15` },
]}
>
<Text style={[styles.paradaTipoText, { color: COLORS.primary }]}>
{getParadaLabel(paradaActual.tipo)}
</Text>
</View>
{distanciaAParada !== null && (
<Text style={styles.paradaDistancia}>
{formatDistance(distanciaAParada)}
</Text>
)}
</View>
<Text style={styles.paradaNombre} numberOfLines={2}>
{paradaActual.punto.nombre}
</Text>
<Text style={styles.paradaDireccion} numberOfLines={1}>
{paradaActual.punto.direccion}
</Text>
{/* Acciones de parada */}
<View style={styles.paradaActions}>
{!isEnParada ? (
<Button
title="Llegue a la parada"
onPress={handleLlegadaParada}
variant="primary"
size="lg"
fullWidth
/>
) : (
<Button
title="Salir de parada"
onPress={handleSalidaParada}
variant="success"
size="lg"
fullWidth
/>
)}
</View>
</View>
</View>
)}
{/* Lista de paradas */}
<TouchableOpacity
style={styles.paradasToggle}
onPress={() => setShowingParadas(!showingParadas)}
>
<Text style={styles.paradasToggleText}>
{showingParadas ? 'Ocultar' : 'Ver'} todas las paradas (
{viaje.paradas.length})
</Text>
</TouchableOpacity>
{showingParadas && (
<View style={styles.paradasList}>
{viaje.paradas.map((parada, index) => (
<View key={parada.id} style={styles.paradaListItem}>
<View
style={[
styles.paradaNumber,
parada.completada && styles.paradaNumberCompleted,
]}
>
<Text
style={[
styles.paradaNumberText,
parada.completada && styles.paradaNumberTextCompleted,
]}
>
{index + 1}
</Text>
</View>
<View style={styles.paradaListInfo}>
<Text
style={[
styles.paradaListNombre,
parada.completada && styles.paradaListNombreCompleted,
]}
numberOfLines={1}
>
{parada.punto.nombre}
</Text>
<Text style={styles.paradaListTipo}>
{getParadaLabel(parada.tipo)}
</Text>
</View>
{parada.completada && (
<Text style={styles.paradaCheckmark}></Text>
)}
</View>
))}
</View>
)}
{/* Acciones rápidas */}
<View style={styles.quickActions}>
<TouchableOpacity
style={styles.quickAction}
onPress={handleRegistrarParada}
>
<Text style={styles.quickActionEmoji}>📍</Text>
<Text style={styles.quickActionText}>Nueva parada</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickAction} onPress={handleCombustible}>
<Text style={styles.quickActionEmoji}></Text>
<Text style={styles.quickActionText}>Combustible</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => navigation.navigate('Camara')}
>
<Text style={styles.quickActionEmoji}>📹</Text>
<Text style={styles.quickActionText}>Dashcam</Text>
</TouchableOpacity>
</View>
{/* Controles principales */}
<View style={styles.mainControls}>
<Button
title={isPaused ? 'Reanudar' : 'Pausar'}
onPress={handlePausarReanudar}
variant={isPaused ? 'success' : 'secondary'}
size="lg"
style={styles.controlButton}
/>
<Button
title="Finalizar"
onPress={handleFinalizar}
variant="danger"
size="lg"
style={styles.controlButton}
/>
</View>
</ScrollView>
<LoadingOverlay visible={isLoading} />
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
mapContainer: {
position: 'relative',
},
mapOverlay: {
position: 'absolute',
top: SPACING.md,
left: SPACING.md,
right: SPACING.md,
flexDirection: 'row',
justifyContent: 'space-between',
},
trackingIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
...SHADOWS.sm,
},
trackingDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: SPACING.xs,
},
trackingText: {
fontSize: FONT_SIZE.xs,
color: COLORS.textSecondary,
},
pausedBadge: {
backgroundColor: COLORS.warning,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
pausedText: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
sosButton: {
position: 'absolute',
bottom: SPACING.md,
right: SPACING.md,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: COLORS.danger,
alignItems: 'center',
justifyContent: 'center',
...SHADOWS.lg,
},
sosText: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.white,
},
panel: {
flex: 1,
backgroundColor: COLORS.white,
borderTopLeftRadius: BORDER_RADIUS.xxl,
borderTopRightRadius: BORDER_RADIUS.xxl,
marginTop: -SPACING.lg,
},
panelContent: {
padding: SPACING.md,
paddingBottom: SPACING.xxl,
},
progressSection: {
marginBottom: SPACING.lg,
},
progressBar: {
height: 8,
backgroundColor: COLORS.gray200,
borderRadius: BORDER_RADIUS.full,
overflow: 'hidden',
marginBottom: SPACING.md,
},
progressFill: {
height: '100%',
backgroundColor: COLORS.primary,
borderRadius: BORDER_RADIUS.full,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-around',
},
progressItem: {
alignItems: 'center',
},
progressValue: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.bold,
color: COLORS.textPrimary,
},
progressLabel: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
paradaSection: {
marginBottom: SPACING.lg,
},
sectionTitle: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textSecondary,
marginBottom: SPACING.sm,
},
paradaCard: {
backgroundColor: COLORS.white,
borderRadius: BORDER_RADIUS.xl,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
},
paradaHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.sm,
},
paradaTipo: {
paddingHorizontal: SPACING.sm,
paddingVertical: SPACING.xs,
borderRadius: BORDER_RADIUS.full,
},
paradaTipoText: {
fontSize: FONT_SIZE.xs,
fontWeight: FONT_WEIGHT.semibold,
},
paradaDistancia: {
fontSize: FONT_SIZE.md,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.primary,
},
paradaNombre: {
fontSize: FONT_SIZE.lg,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textPrimary,
marginBottom: SPACING.xs,
},
paradaDireccion: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
marginBottom: SPACING.md,
},
paradaActions: {
marginTop: SPACING.sm,
},
paradasToggle: {
paddingVertical: SPACING.sm,
alignItems: 'center',
},
paradasToggleText: {
fontSize: FONT_SIZE.sm,
color: COLORS.primary,
fontWeight: FONT_WEIGHT.medium,
},
paradasList: {
marginBottom: SPACING.lg,
},
paradaListItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
paradaNumber: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: COLORS.gray200,
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING.sm,
},
paradaNumberCompleted: {
backgroundColor: COLORS.success,
},
paradaNumberText: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT_WEIGHT.semibold,
color: COLORS.textSecondary,
},
paradaNumberTextCompleted: {
color: COLORS.white,
},
paradaListInfo: {
flex: 1,
},
paradaListNombre: {
fontSize: FONT_SIZE.md,
color: COLORS.textPrimary,
},
paradaListNombreCompleted: {
color: COLORS.textTertiary,
textDecorationLine: 'line-through',
},
paradaListTipo: {
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
paradaCheckmark: {
fontSize: FONT_SIZE.lg,
color: COLORS.success,
},
quickActions: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: SPACING.md,
marginBottom: SPACING.lg,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: COLORS.border,
},
quickAction: {
alignItems: 'center',
},
quickActionEmoji: {
fontSize: 28,
marginBottom: SPACING.xs,
},
quickActionText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
},
mainControls: {
flexDirection: 'row',
gap: SPACING.md,
},
controlButton: {
flex: 1,
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: FONT_SIZE.lg,
color: COLORS.textSecondary,
},
});
export default ViajeActivoScreen;

View File

@@ -0,0 +1,13 @@
/**
* Exportación centralizada de pantallas
*/
export { LoginScreen, VerifyCodeScreen } from './Login';
export { HomeScreen, default as Home } from './Home';
export { ViajeActivoScreen, default as ViajeActivo } from './ViajeActivo';
export { RegistrarParadaScreen, default as RegistrarParada } from './RegistrarParada';
export { CombustibleScreen, default as Combustible } from './Combustible';
export { MensajesScreen, default as Mensajes } from './Mensajes';
export { EmergenciaScreen, default as Emergencia } from './Emergencia';
export { PerfilScreen, default as Perfil } from './Perfil';
export { CamaraScreen, default as Camara } from './Camara';

477
mobile/src/services/api.ts Normal file
View File

@@ -0,0 +1,477 @@
/**
* Servicio de API usando Axios
* Configurado con headers de autenticación y manejo offline
*/
import axios, {
AxiosInstance,
AxiosError,
InternalAxiosRequestConfig,
AxiosResponse,
} from 'axios';
import { storage, STORAGE_KEYS } from './storage';
import type {
ApiResponse,
AuthResponse,
Conductor,
Viaje,
Parada,
CargaCombustible,
Mensaje,
Emergencia,
Ubicacion,
EstadisticasDia,
EstadisticasConductor,
PaginatedResponse,
LoginRequest,
VerifyCodeRequest,
} from '../types';
// Configuración base
const API_BASE_URL = __DEV__
? 'http://192.168.1.100:8000/api/v1' // Cambiar por IP local en desarrollo
: 'https://api.adan.com/api/v1';
const API_KEY = 'your-api-key-here'; // Configurar en producción
// Cola de peticiones offline
interface QueuedRequest {
id: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
data?: unknown;
timestamp: number;
}
let requestQueue: QueuedRequest[] = [];
let isOnline = true;
// Crear instancia de Axios
const createApiInstance = (): AxiosInstance => {
const instance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
});
// Interceptor de request para añadir headers
instance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Añadir token de autenticación
const token = await storage.get<string>(STORAGE_KEYS.AUTH_TOKEN);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Añadir Device ID
const dispositivo = await storage.get<{ id: string }>(STORAGE_KEYS.DISPOSITIVO);
if (dispositivo?.id) {
config.headers['X-Device-ID'] = dispositivo.id;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de response para manejar errores
instance.interceptors.response.use(
(response: AxiosResponse) => {
isOnline = true;
return response;
},
async (error: AxiosError) => {
// Error de red - estamos offline
if (!error.response) {
isOnline = false;
console.log('Sin conexión - guardando petición para después');
// Guardar peticiones que modifican datos para sincronizar después
if (
error.config &&
['POST', 'PUT', 'PATCH', 'DELETE'].includes(error.config.method?.toUpperCase() || '')
) {
await queueRequest(error.config);
}
throw new Error('Sin conexión a internet');
}
// Error 401 - Token expirado
if (error.response.status === 401) {
await storage.remove(STORAGE_KEYS.AUTH_TOKEN);
// El store de auth manejará la redirección al login
}
// Error del servidor
const errorMessage =
(error.response.data as { message?: string })?.message ||
'Error en el servidor';
throw new Error(errorMessage);
}
);
return instance;
};
// Función para encolar peticiones offline
const queueRequest = async (config: InternalAxiosRequestConfig): Promise<void> => {
const request: QueuedRequest = {
id: Date.now().toString(),
method: config.method?.toUpperCase() as QueuedRequest['method'],
url: config.url || '',
data: config.data,
timestamp: Date.now(),
};
requestQueue.push(request);
await storage.set(STORAGE_KEYS.MENSAJES_PENDIENTES, requestQueue);
};
// Función para sincronizar peticiones pendientes
const syncPendingRequests = async (): Promise<void> => {
if (!isOnline || requestQueue.length === 0) return;
const pending = [...requestQueue];
requestQueue = [];
for (const request of pending) {
try {
await api.request({
method: request.method,
url: request.url,
data: request.data,
});
} catch (error) {
console.error('Error sincronizando petición:', error);
// Re-encolar si falla
requestQueue.push(request);
}
}
await storage.set(STORAGE_KEYS.MENSAJES_PENDIENTES, requestQueue);
};
// Instancia de API
const api = createApiInstance();
// ==========================================
// ENDPOINTS DE AUTENTICACIÓN
// ==========================================
export const authApi = {
/**
* Solicita código de verificación
*/
requestCode: async (data: LoginRequest): Promise<ApiResponse<{ mensaje: string }>> => {
const response = await api.post('/auth/conductor/request-code', data);
return response.data;
},
/**
* Verifica código y autentica
*/
verifyCode: async (data: VerifyCodeRequest): Promise<ApiResponse<AuthResponse>> => {
const response = await api.post('/auth/conductor/verify', data);
return response.data;
},
/**
* Cierra sesión
*/
logout: async (): Promise<void> => {
await api.post('/auth/conductor/logout');
},
/**
* Refresca el token
*/
refreshToken: async (): Promise<ApiResponse<{ token: string }>> => {
const response = await api.post('/auth/conductor/refresh');
return response.data;
},
/**
* Obtiene perfil del conductor
*/
getProfile: async (): Promise<ApiResponse<Conductor>> => {
const response = await api.get('/conductor/perfil');
return response.data;
},
};
// ==========================================
// ENDPOINTS DE VIAJES
// ==========================================
export const viajesApi = {
/**
* Obtiene viajes del conductor
*/
getViajes: async (
estado?: string,
pagina = 1
): Promise<ApiResponse<PaginatedResponse<Viaje>>> => {
const response = await api.get('/conductor/viajes', {
params: { estado, pagina },
});
return response.data;
},
/**
* Obtiene viaje activo
*/
getViajeActivo: async (): Promise<ApiResponse<Viaje | null>> => {
const response = await api.get('/conductor/viajes/activo');
return response.data;
},
/**
* Inicia un viaje
*/
iniciarViaje: async (viajeId: string): Promise<ApiResponse<Viaje>> => {
const response = await api.post(`/conductor/viajes/${viajeId}/iniciar`);
return response.data;
},
/**
* Pausa un viaje
*/
pausarViaje: async (viajeId: string, motivo?: string): Promise<ApiResponse<Viaje>> => {
const response = await api.post(`/conductor/viajes/${viajeId}/pausar`, { motivo });
return response.data;
},
/**
* Reanuda un viaje
*/
reanudarViaje: async (viajeId: string): Promise<ApiResponse<Viaje>> => {
const response = await api.post(`/conductor/viajes/${viajeId}/reanudar`);
return response.data;
},
/**
* Finaliza un viaje
*/
finalizarViaje: async (
viajeId: string,
notas?: string
): Promise<ApiResponse<Viaje>> => {
const response = await api.post(`/conductor/viajes/${viajeId}/finalizar`, { notas });
return response.data;
},
/**
* Obtiene próximo viaje asignado
*/
getProximoViaje: async (): Promise<ApiResponse<Viaje | null>> => {
const response = await api.get('/conductor/viajes/proximo');
return response.data;
},
};
// ==========================================
// ENDPOINTS DE PARADAS
// ==========================================
export const paradasApi = {
/**
* Registra llegada a una parada
*/
registrarLlegada: async (
paradaId: string,
ubicacion: Ubicacion
): Promise<ApiResponse<Parada>> => {
const response = await api.post(`/conductor/paradas/${paradaId}/llegada`, {
ubicacion,
});
return response.data;
},
/**
* Registra salida de una parada
*/
registrarSalida: async (
paradaId: string,
notas?: string
): Promise<ApiResponse<Parada>> => {
const response = await api.post(`/conductor/paradas/${paradaId}/salida`, { notas });
return response.data;
},
/**
* Registra una parada no programada
*/
registrarParadaNoProgramada: async (data: {
viajeId: string;
tipo: string;
ubicacion: Ubicacion;
notas?: string;
}): Promise<ApiResponse<Parada>> => {
const response = await api.post('/conductor/paradas', data);
return response.data;
},
/**
* Sube foto de parada
*/
subirFotoParada: async (
paradaId: string,
fotoBase64: string
): Promise<ApiResponse<{ url: string }>> => {
const response = await api.post(`/conductor/paradas/${paradaId}/foto`, {
foto: fotoBase64,
});
return response.data;
},
};
// ==========================================
// ENDPOINTS DE UBICACIÓN
// ==========================================
export const ubicacionApi = {
/**
* Envía ubicación actual
*/
enviarUbicacion: async (ubicacion: Ubicacion): Promise<ApiResponse<void>> => {
const response = await api.post('/conductor/ubicacion', ubicacion);
return response.data;
},
/**
* Envía múltiples ubicaciones (sincronización offline)
*/
enviarUbicacionesBatch: async (
ubicaciones: Ubicacion[]
): Promise<ApiResponse<{ sincronizadas: number }>> => {
const response = await api.post('/conductor/ubicacion/batch', { ubicaciones });
return response.data;
},
};
// ==========================================
// ENDPOINTS DE COMBUSTIBLE
// ==========================================
export const combustibleApi = {
/**
* Registra carga de combustible
*/
registrarCarga: async (
data: Omit<CargaCombustible, 'id'>
): Promise<ApiResponse<CargaCombustible>> => {
const response = await api.post('/conductor/combustible', data);
return response.data;
},
/**
* Obtiene historial de cargas
*/
getHistorial: async (
pagina = 1
): Promise<ApiResponse<PaginatedResponse<CargaCombustible>>> => {
const response = await api.get('/conductor/combustible', { params: { pagina } });
return response.data;
},
};
// ==========================================
// ENDPOINTS DE MENSAJES
// ==========================================
export const mensajesApi = {
/**
* Obtiene mensajes
*/
getMensajes: async (
pagina = 1
): Promise<ApiResponse<PaginatedResponse<Mensaje>>> => {
const response = await api.get('/conductor/mensajes', { params: { pagina } });
return response.data;
},
/**
* Envía mensaje
*/
enviarMensaje: async (contenido: string): Promise<ApiResponse<Mensaje>> => {
const response = await api.post('/conductor/mensajes', { contenido });
return response.data;
},
/**
* Marca mensaje como leído
*/
marcarLeido: async (mensajeId: string): Promise<ApiResponse<void>> => {
const response = await api.patch(`/conductor/mensajes/${mensajeId}/leido`);
return response.data;
},
/**
* Obtiene cantidad de mensajes no leídos
*/
getNoLeidos: async (): Promise<ApiResponse<{ cantidad: number }>> => {
const response = await api.get('/conductor/mensajes/no-leidos');
return response.data;
},
};
// ==========================================
// ENDPOINTS DE EMERGENCIA
// ==========================================
export const emergenciaApi = {
/**
* Reporta emergencia
*/
reportarEmergencia: async (
data: Omit<Emergencia, 'id' | 'atendida'>
): Promise<ApiResponse<Emergencia>> => {
const response = await api.post('/conductor/emergencia', data);
return response.data;
},
/**
* Cancela alerta de emergencia
*/
cancelarEmergencia: async (emergenciaId: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/conductor/emergencia/${emergenciaId}`);
return response.data;
},
};
// ==========================================
// ENDPOINTS DE ESTADÍSTICAS
// ==========================================
export const estadisticasApi = {
/**
* Obtiene estadísticas del día
*/
getEstadisticasDia: async (
fecha?: string
): Promise<ApiResponse<EstadisticasDia>> => {
const response = await api.get('/conductor/estadisticas/dia', {
params: { fecha },
});
return response.data;
},
/**
* Obtiene estadísticas generales del conductor
*/
getEstadisticasConductor: async (): Promise<ApiResponse<EstadisticasConductor>> => {
const response = await api.get('/conductor/estadisticas');
return response.data;
},
};
// Exportar funciones de utilidad
export { syncPendingRequests, isOnline };
export default api;

View File

@@ -0,0 +1,13 @@
/**
* Exportación centralizada de servicios
*/
export * from './api';
export * from './storage';
export * from './location';
export * from './notifications';
export { default as api } from './api';
export { default as storage } from './storage';
export { default as locationService } from './location';
export { default as notificationService } from './notifications';

View File

@@ -0,0 +1,404 @@
/**
* Servicio de geolocalización con tracking en background
* Envía ubicación cada 10 segundos con manejo offline
*/
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
import { storage, STORAGE_KEYS } from './storage';
import { ubicacionApi } from './api';
import type { Ubicacion, UbicacionOffline } from '../types';
// Nombre de la tarea de background
const LOCATION_TASK_NAME = 'adan-background-location';
// Configuración
const CONFIG = {
INTERVAL_MS: 10000, // 10 segundos
DISTANCE_FILTER: 10, // metros mínimos para reportar
MAX_OFFLINE_QUEUE: 1000, // máximo de ubicaciones en cola
BATCH_SIZE: 50, // tamaño de lote para sincronización
ACCURACY: Location.Accuracy.High,
};
// Estado del servicio
let isTracking = false;
let currentViajeId: string | null = null;
let watchSubscription: Location.LocationSubscription | null = null;
// ==========================================
// DEFINICIÓN DE TAREA EN BACKGROUND
// ==========================================
TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => {
if (error) {
console.error('Error en tarea de ubicación:', error);
return;
}
if (data) {
const { locations } = data as { locations: Location.LocationObject[] };
for (const location of locations) {
await processLocation(location);
}
}
});
// ==========================================
// FUNCIONES PRINCIPALES
// ==========================================
/**
* Solicita permisos de ubicación
*/
export const requestLocationPermissions = async (): Promise<boolean> => {
try {
// Permiso de ubicación en primer plano
const { status: foregroundStatus } =
await Location.requestForegroundPermissionsAsync();
if (foregroundStatus !== 'granted') {
console.log('Permiso de ubicación en primer plano denegado');
return false;
}
// Permiso de ubicación en background
const { status: backgroundStatus } =
await Location.requestBackgroundPermissionsAsync();
if (backgroundStatus !== 'granted') {
console.log('Permiso de ubicación en background denegado');
// Aún podemos funcionar solo en primer plano
}
return true;
} catch (error) {
console.error('Error solicitando permisos:', error);
return false;
}
};
/**
* Verifica el estado de los permisos
*/
export const checkLocationPermissions = async (): Promise<{
foreground: boolean;
background: boolean;
}> => {
const foreground = await Location.getForegroundPermissionsAsync();
const background = await Location.getBackgroundPermissionsAsync();
return {
foreground: foreground.status === 'granted',
background: background.status === 'granted',
};
};
/**
* Obtiene la ubicación actual
*/
export const getCurrentLocation = async (): Promise<Ubicacion | null> => {
try {
const location = await Location.getCurrentPositionAsync({
accuracy: CONFIG.ACCURACY,
});
return convertToUbicacion(location);
} catch (error) {
console.error('Error obteniendo ubicación actual:', error);
return null;
}
};
/**
* Inicia el tracking de ubicación
*/
export const startLocationTracking = async (viajeId?: string): Promise<boolean> => {
if (isTracking) {
console.log('El tracking ya está activo');
return true;
}
const hasPermissions = await requestLocationPermissions();
if (!hasPermissions) {
return false;
}
currentViajeId = viajeId || null;
try {
// Intentar background tracking primero
const permissions = await checkLocationPermissions();
if (permissions.background) {
await startBackgroundTracking();
} else {
// Fallback a tracking en primer plano
await startForegroundTracking();
}
isTracking = true;
console.log('Tracking de ubicación iniciado');
return true;
} catch (error) {
console.error('Error iniciando tracking:', error);
return false;
}
};
/**
* Detiene el tracking de ubicación
*/
export const stopLocationTracking = async (): Promise<void> => {
try {
// Detener tracking en background
const isRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME);
if (isRegistered) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
}
// Detener tracking en primer plano
if (watchSubscription) {
watchSubscription.remove();
watchSubscription = null;
}
isTracking = false;
currentViajeId = null;
console.log('Tracking de ubicación detenido');
} catch (error) {
console.error('Error deteniendo tracking:', error);
}
};
/**
* Verifica si el tracking está activo
*/
export const isTrackingActive = (): boolean => {
return isTracking;
};
/**
* Sincroniza ubicaciones pendientes
*/
export const syncOfflineLocations = async (): Promise<number> => {
try {
const pendientes = await storage.get<UbicacionOffline[]>(
STORAGE_KEYS.UBICACIONES_OFFLINE
);
if (!pendientes || pendientes.length === 0) {
return 0;
}
const noSincronizadas = pendientes.filter((u) => !u.sincronizada);
if (noSincronizadas.length === 0) {
return 0;
}
// Enviar en lotes
let sincronizadas = 0;
for (let i = 0; i < noSincronizadas.length; i += CONFIG.BATCH_SIZE) {
const batch = noSincronizadas.slice(i, i + CONFIG.BATCH_SIZE);
try {
const response = await ubicacionApi.enviarUbicacionesBatch(
batch.map((u) => ({
latitud: u.latitud,
longitud: u.longitud,
altitud: u.altitud,
velocidad: u.velocidad,
precision: u.precision,
rumbo: u.rumbo,
timestamp: u.timestamp,
}))
);
if (response.success) {
sincronizadas += response.data?.sincronizadas || batch.length;
// Marcar como sincronizadas
batch.forEach((ubicacion) => {
ubicacion.sincronizada = true;
});
}
} catch (error) {
console.error('Error sincronizando lote:', error);
break;
}
}
// Actualizar almacenamiento
const actualizadas = pendientes.map((u) => {
const sincronizada = noSincronizadas.find((ns) => ns.id === u.id);
return sincronizada || u;
});
// Mantener solo las no sincronizadas (limpiar las viejas)
const mantener = actualizadas.filter(
(u) => !u.sincronizada || Date.now() - u.timestamp < 3600000 // 1 hora
);
await storage.set(STORAGE_KEYS.UBICACIONES_OFFLINE, mantener);
return sincronizadas;
} catch (error) {
console.error('Error en sincronización:', error);
return 0;
}
};
/**
* Obtiene el conteo de ubicaciones pendientes
*/
export const getPendingLocationsCount = async (): Promise<number> => {
const pendientes = await storage.get<UbicacionOffline[]>(
STORAGE_KEYS.UBICACIONES_OFFLINE
);
if (!pendientes) return 0;
return pendientes.filter((u) => !u.sincronizada).length;
};
// ==========================================
// FUNCIONES INTERNAS
// ==========================================
/**
* Inicia tracking en background
*/
const startBackgroundTracking = async (): Promise<void> => {
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
accuracy: CONFIG.ACCURACY,
timeInterval: CONFIG.INTERVAL_MS,
distanceInterval: CONFIG.DISTANCE_FILTER,
deferredUpdatesInterval: CONFIG.INTERVAL_MS,
deferredUpdatesDistance: CONFIG.DISTANCE_FILTER,
showsBackgroundLocationIndicator: true,
foregroundService: {
notificationTitle: 'Adan - Rastreo activo',
notificationBody: 'Enviando ubicación en tiempo real',
notificationColor: '#3b82f6',
},
pausesUpdatesAutomatically: false,
activityType: Location.ActivityType.AutomotiveNavigation,
});
};
/**
* Inicia tracking en primer plano
*/
const startForegroundTracking = async (): Promise<void> => {
watchSubscription = await Location.watchPositionAsync(
{
accuracy: CONFIG.ACCURACY,
timeInterval: CONFIG.INTERVAL_MS,
distanceInterval: CONFIG.DISTANCE_FILTER,
},
async (location) => {
await processLocation(location);
}
);
};
/**
* Procesa una ubicación recibida
*/
const processLocation = async (location: Location.LocationObject): Promise<void> => {
const ubicacion = convertToUbicacion(location);
try {
// Intentar enviar a la API
await ubicacionApi.enviarUbicacion(ubicacion);
} catch (error) {
// Si falla, guardar offline
console.log('Guardando ubicación offline');
await saveLocationOffline(ubicacion);
}
};
/**
* Guarda ubicación offline
*/
const saveLocationOffline = async (ubicacion: Ubicacion): Promise<void> => {
const ubicacionOffline: UbicacionOffline = {
...ubicacion,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
viajeId: currentViajeId || undefined,
sincronizada: false,
};
const pendientes = await storage.get<UbicacionOffline[]>(
STORAGE_KEYS.UBICACIONES_OFFLINE
);
const lista = pendientes || [];
// Limitar tamaño de cola
if (lista.length >= CONFIG.MAX_OFFLINE_QUEUE) {
// Eliminar las más antiguas sincronizadas
const noSincronizadas = lista.filter((u) => !u.sincronizada);
lista.length = 0;
lista.push(...noSincronizadas.slice(-CONFIG.MAX_OFFLINE_QUEUE + 1));
}
lista.push(ubicacionOffline);
await storage.set(STORAGE_KEYS.UBICACIONES_OFFLINE, lista);
};
/**
* Convierte LocationObject de expo a nuestro tipo Ubicacion
*/
const convertToUbicacion = (location: Location.LocationObject): Ubicacion => {
return {
latitud: location.coords.latitude,
longitud: location.coords.longitude,
altitud: location.coords.altitude ?? undefined,
velocidad: location.coords.speed
? location.coords.speed * 3.6 // Convertir m/s a km/h
: undefined,
precision: location.coords.accuracy ?? undefined,
rumbo: location.coords.heading ?? undefined,
timestamp: location.timestamp,
};
};
/**
* Calcula distancia entre dos puntos (Haversine)
*/
export const calculateDistance = (
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number => {
const R = 6371; // Radio de la Tierra en km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const toRad = (deg: number): number => {
return deg * (Math.PI / 180);
};
export default {
requestLocationPermissions,
checkLocationPermissions,
getCurrentLocation,
startLocationTracking,
stopLocationTracking,
isTrackingActive,
syncOfflineLocations,
getPendingLocationsCount,
calculateDistance,
};

View File

@@ -0,0 +1,415 @@
/**
* Servicio de notificaciones push
* Maneja registro, recepción y gestión de notificaciones
*/
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { storage, STORAGE_KEYS } from './storage';
// Tipos de notificación
export type NotificationType =
| 'mensaje'
| 'viaje_asignado'
| 'alerta'
| 'emergencia'
| 'recordatorio';
export interface NotificationData {
type: NotificationType;
title: string;
body: string;
data?: Record<string, unknown>;
}
// Configuración del handler de notificaciones
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
// Listeners activos
let notificationListener: Notifications.Subscription | null = null;
let responseListener: Notifications.Subscription | null = null;
// Callbacks personalizados
let onNotificationReceived: ((notification: NotificationData) => void) | null = null;
let onNotificationResponse: ((response: Notifications.NotificationResponse) => void) | null =
null;
// ==========================================
// FUNCIONES PRINCIPALES
// ==========================================
/**
* Solicita permisos de notificación
*/
export const requestNotificationPermissions = async (): Promise<boolean> => {
if (!Device.isDevice) {
console.log('Las notificaciones push requieren un dispositivo físico');
return false;
}
try {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Permiso de notificaciones denegado');
return false;
}
return true;
} catch (error) {
console.error('Error solicitando permisos de notificación:', error);
return false;
}
};
/**
* Obtiene el token de push notifications
*/
export const getPushToken = async (): Promise<string | null> => {
try {
const hasPermission = await requestNotificationPermissions();
if (!hasPermission) {
return null;
}
// Configuración específica de Android
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Adan Conductor',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#3b82f6',
sound: 'default',
});
// Canal para emergencias
await Notifications.setNotificationChannelAsync('emergencias', {
name: 'Emergencias',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 500, 250, 500],
lightColor: '#ef4444',
sound: 'default',
bypassDnd: true,
});
// Canal para mensajes
await Notifications.setNotificationChannelAsync('mensajes', {
name: 'Mensajes',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#3b82f6',
sound: 'default',
});
}
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'your-project-id', // Configurar con tu project ID de Expo
});
// Guardar token localmente
const dispositivo = await storage.get<{ id: string }>(STORAGE_KEYS.DISPOSITIVO);
if (dispositivo) {
await storage.set(STORAGE_KEYS.DISPOSITIVO, {
...dispositivo,
tokenPush: token.data,
});
}
return token.data;
} catch (error) {
console.error('Error obteniendo push token:', error);
return null;
}
};
/**
* Inicializa los listeners de notificaciones
*/
export const initializeNotificationListeners = (callbacks?: {
onReceived?: (notification: NotificationData) => void;
onResponse?: (response: Notifications.NotificationResponse) => void;
}): void => {
// Guardar callbacks personalizados
if (callbacks?.onReceived) {
onNotificationReceived = callbacks.onReceived;
}
if (callbacks?.onResponse) {
onNotificationResponse = callbacks.onResponse;
}
// Limpiar listeners existentes
removeNotificationListeners();
// Listener para notificaciones recibidas
notificationListener = Notifications.addNotificationReceivedListener(
(notification) => {
const data: NotificationData = {
type: (notification.request.content.data?.type as NotificationType) || 'alerta',
title: notification.request.content.title || '',
body: notification.request.content.body || '',
data: notification.request.content.data as Record<string, unknown>,
};
console.log('Notificación recibida:', data);
if (onNotificationReceived) {
onNotificationReceived(data);
}
}
);
// Listener para respuestas a notificaciones
responseListener = Notifications.addNotificationResponseReceivedListener(
(response) => {
console.log('Respuesta a notificación:', response);
if (onNotificationResponse) {
onNotificationResponse(response);
}
}
);
};
/**
* Elimina los listeners de notificaciones
*/
export const removeNotificationListeners = (): void => {
if (notificationListener) {
Notifications.removeNotificationSubscription(notificationListener);
notificationListener = null;
}
if (responseListener) {
Notifications.removeNotificationSubscription(responseListener);
responseListener = null;
}
};
/**
* Programa una notificación local
*/
export const scheduleLocalNotification = async (
notification: NotificationData,
trigger?: Notifications.NotificationTriggerInput
): Promise<string> => {
try {
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: notification.title,
body: notification.body,
data: {
type: notification.type,
...notification.data,
},
sound: true,
priority: Notifications.AndroidNotificationPriority.HIGH,
},
trigger: trigger || null, // null = inmediato
});
return notificationId;
} catch (error) {
console.error('Error programando notificación:', error);
throw error;
}
};
/**
* Muestra una notificación inmediata
*/
export const showInstantNotification = async (
title: string,
body: string,
type: NotificationType = 'alerta',
data?: Record<string, unknown>
): Promise<string> => {
return scheduleLocalNotification({
type,
title,
body,
data,
});
};
/**
* Programa un recordatorio
*/
export const scheduleReminder = async (
title: string,
body: string,
delaySeconds: number
): Promise<string> => {
return scheduleLocalNotification(
{
type: 'recordatorio',
title,
body,
},
{
seconds: delaySeconds,
}
);
};
/**
* Cancela una notificación programada
*/
export const cancelNotification = async (notificationId: string): Promise<void> => {
try {
await Notifications.cancelScheduledNotificationAsync(notificationId);
} catch (error) {
console.error('Error cancelando notificación:', error);
}
};
/**
* Cancela todas las notificaciones programadas
*/
export const cancelAllNotifications = async (): Promise<void> => {
try {
await Notifications.cancelAllScheduledNotificationsAsync();
} catch (error) {
console.error('Error cancelando notificaciones:', error);
}
};
/**
* Limpia todas las notificaciones mostradas
*/
export const dismissAllNotifications = async (): Promise<void> => {
try {
await Notifications.dismissAllNotificationsAsync();
} catch (error) {
console.error('Error limpiando notificaciones:', error);
}
};
/**
* Obtiene el badge count actual
*/
export const getBadgeCount = async (): Promise<number> => {
try {
return await Notifications.getBadgeCountAsync();
} catch (error) {
console.error('Error obteniendo badge count:', error);
return 0;
}
};
/**
* Establece el badge count
*/
export const setBadgeCount = async (count: number): Promise<void> => {
try {
await Notifications.setBadgeCountAsync(count);
} catch (error) {
console.error('Error estableciendo badge count:', error);
}
};
/**
* Obtiene la última notificación que abrió la app
*/
export const getLastNotificationResponse =
async (): Promise<Notifications.NotificationResponse | null> => {
try {
return await Notifications.getLastNotificationResponseAsync();
} catch (error) {
console.error('Error obteniendo última notificación:', error);
return null;
}
};
/**
* Verifica el estado de los permisos
*/
export const getNotificationPermissionStatus = async (): Promise<
'granted' | 'denied' | 'undetermined'
> => {
try {
const { status } = await Notifications.getPermissionsAsync();
return status;
} catch (error) {
console.error('Error verificando permisos:', error);
return 'undetermined';
}
};
// Notificaciones específicas de la aplicación
/**
* Notifica nuevo viaje asignado
*/
export const notifyNewTrip = async (
origen: string,
destino: string,
viajeId: string
): Promise<string> => {
return showInstantNotification(
'Nuevo viaje asignado',
`De ${origen} a ${destino}`,
'viaje_asignado',
{ viajeId }
);
};
/**
* Notifica nuevo mensaje
*/
export const notifyNewMessage = async (
remitente: string,
preview: string,
mensajeId: string
): Promise<string> => {
return showInstantNotification(
`Mensaje de ${remitente}`,
preview.length > 100 ? `${preview.substring(0, 100)}...` : preview,
'mensaje',
{ mensajeId }
);
};
/**
* Notifica alerta de emergencia
*/
export const notifyEmergency = async (
mensaje: string,
emergenciaId: string
): Promise<string> => {
return showInstantNotification('ALERTA DE EMERGENCIA', mensaje, 'emergencia', {
emergenciaId,
});
};
export default {
requestNotificationPermissions,
getPushToken,
initializeNotificationListeners,
removeNotificationListeners,
scheduleLocalNotification,
showInstantNotification,
scheduleReminder,
cancelNotification,
cancelAllNotifications,
dismissAllNotifications,
getBadgeCount,
setBadgeCount,
getLastNotificationResponse,
getNotificationPermissionStatus,
notifyNewTrip,
notifyNewMessage,
notifyEmergency,
};

View File

@@ -0,0 +1,207 @@
/**
* Servicio de almacenamiento local usando AsyncStorage
* Wrapper con tipado y manejo de errores
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
// Claves de almacenamiento
export const STORAGE_KEYS = {
AUTH_TOKEN: '@adan/auth_token',
CONDUCTOR: '@adan/conductor',
DISPOSITIVO: '@adan/dispositivo',
UBICACIONES_OFFLINE: '@adan/ubicaciones_offline',
VIAJE_ACTIVO: '@adan/viaje_activo',
CONFIGURACION: '@adan/configuracion',
ULTIMO_SYNC: '@adan/ultimo_sync',
MENSAJES_PENDIENTES: '@adan/mensajes_pendientes',
PARADAS_PENDIENTES: '@adan/paradas_pendientes',
COMBUSTIBLE_PENDIENTE: '@adan/combustible_pendiente',
} as const;
type StorageKey = typeof STORAGE_KEYS[keyof typeof STORAGE_KEYS];
class StorageService {
/**
* Guarda un valor en el almacenamiento
*/
async set<T>(key: StorageKey, value: T): Promise<void> {
try {
const jsonValue = JSON.stringify(value);
await AsyncStorage.setItem(key, jsonValue);
} catch (error) {
console.error(`Error guardando ${key}:`, error);
throw new Error(`No se pudo guardar en ${key}`);
}
}
/**
* Obtiene un valor del almacenamiento
*/
async get<T>(key: StorageKey): Promise<T | null> {
try {
const jsonValue = await AsyncStorage.getItem(key);
if (jsonValue === null) {
return null;
}
return JSON.parse(jsonValue) as T;
} catch (error) {
console.error(`Error leyendo ${key}:`, error);
return null;
}
}
/**
* Elimina un valor del almacenamiento
*/
async remove(key: StorageKey): Promise<void> {
try {
await AsyncStorage.removeItem(key);
} catch (error) {
console.error(`Error eliminando ${key}:`, error);
throw new Error(`No se pudo eliminar ${key}`);
}
}
/**
* Elimina múltiples valores
*/
async removeMultiple(keys: StorageKey[]): Promise<void> {
try {
await AsyncStorage.multiRemove(keys);
} catch (error) {
console.error('Error eliminando múltiples claves:', error);
throw new Error('No se pudieron eliminar las claves');
}
}
/**
* Obtiene múltiples valores
*/
async getMultiple<T extends Record<string, unknown>>(
keys: StorageKey[]
): Promise<Partial<T>> {
try {
const pairs = await AsyncStorage.multiGet(keys);
const result: Record<string, unknown> = {};
pairs.forEach(([key, value]) => {
if (value !== null) {
try {
result[key] = JSON.parse(value);
} catch {
result[key] = value;
}
}
});
return result as Partial<T>;
} catch (error) {
console.error('Error leyendo múltiples claves:', error);
return {};
}
}
/**
* Guarda múltiples valores
*/
async setMultiple(data: Array<[StorageKey, unknown]>): Promise<void> {
try {
const pairs: Array<[string, string]> = data.map(([key, value]) => [
key,
JSON.stringify(value),
]);
await AsyncStorage.multiSet(pairs);
} catch (error) {
console.error('Error guardando múltiples valores:', error);
throw new Error('No se pudieron guardar los valores');
}
}
/**
* Limpia todo el almacenamiento de la app
*/
async clearAll(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const adanKeys = keys.filter((key) => key.startsWith('@adan/'));
await AsyncStorage.multiRemove(adanKeys);
} catch (error) {
console.error('Error limpiando almacenamiento:', error);
throw new Error('No se pudo limpiar el almacenamiento');
}
}
/**
* Verifica si existe una clave
*/
async exists(key: StorageKey): Promise<boolean> {
const value = await this.get(key);
return value !== null;
}
/**
* Añade un elemento a un array existente
*/
async appendToArray<T>(key: StorageKey, item: T): Promise<void> {
const existing = await this.get<T[]>(key);
const array = existing || [];
array.push(item);
await this.set(key, array);
}
/**
* Elimina un elemento de un array por predicado
*/
async removeFromArray<T>(
key: StorageKey,
predicate: (item: T) => boolean
): Promise<void> {
const existing = await this.get<T[]>(key);
if (existing) {
const filtered = existing.filter((item) => !predicate(item));
await this.set(key, filtered);
}
}
/**
* Actualiza un elemento en un array
*/
async updateInArray<T extends { id: string }>(
key: StorageKey,
id: string,
updates: Partial<T>
): Promise<void> {
const existing = await this.get<T[]>(key);
if (existing) {
const updated = existing.map((item) =>
item.id === id ? { ...item, ...updates } : item
);
await this.set(key, updated);
}
}
/**
* Obtiene el tamaño aproximado del almacenamiento
*/
async getStorageSize(): Promise<number> {
try {
const keys = await AsyncStorage.getAllKeys();
const adanKeys = keys.filter((key) => key.startsWith('@adan/'));
const pairs = await AsyncStorage.multiGet(adanKeys);
let totalSize = 0;
pairs.forEach(([key, value]) => {
totalSize += key.length + (value?.length || 0);
});
return totalSize;
} catch (error) {
console.error('Error calculando tamaño:', error);
return 0;
}
}
}
export const storage = new StorageService();
export default storage;

View File

@@ -0,0 +1,281 @@
/**
* Store de autenticación usando Zustand
* Gestiona el estado del conductor, token y dispositivo
*/
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import * as Application from 'expo-application';
import { authApi } from '../services/api';
import { storage, STORAGE_KEYS } from '../services/storage';
import { getPushToken } from '../services/notifications';
import type { Conductor, Dispositivo, AuthState } from '../types';
// Genera un ID único para el dispositivo
const generateDeviceId = (): string => {
return `${Platform.OS}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Obtiene información del dispositivo
const getDeviceInfo = async (): Promise<Dispositivo> => {
const existingDevice = await storage.get<Dispositivo>(STORAGE_KEYS.DISPOSITIVO);
if (existingDevice) {
return existingDevice;
}
const dispositivo: Dispositivo = {
id: generateDeviceId(),
plataforma: Platform.OS as 'ios' | 'android',
modelo: `${Platform.OS} ${Platform.Version}`,
versionApp: Application.nativeApplicationVersion || '1.0.0',
};
await storage.set(STORAGE_KEYS.DISPOSITIVO, dispositivo);
return dispositivo;
};
interface AuthStore extends AuthState {
// Acciones
initialize: () => Promise<void>;
requestCode: (telefono: string) => Promise<{ success: boolean; error?: string }>;
verifyCode: (
telefono: string,
codigo: string
) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
refreshToken: () => Promise<boolean>;
updateConductor: (conductor: Partial<Conductor>) => void;
updatePushToken: (token: string) => Promise<void>;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
// Estado inicial
conductor: null,
token: null,
dispositivo: null,
isAuthenticated: false,
isLoading: true,
// Inicializa el store (verificar sesión existente)
initialize: async () => {
try {
set({ isLoading: true });
// Obtener información del dispositivo
const dispositivo = await getDeviceInfo();
set({ dispositivo });
// Verificar si hay token guardado
const savedToken = await storage.get<string>(STORAGE_KEYS.AUTH_TOKEN);
const savedConductor = await storage.get<Conductor>(STORAGE_KEYS.CONDUCTOR);
if (savedToken && savedConductor) {
// Verificar que el token sigue siendo válido
try {
const response = await authApi.getProfile();
if (response.success && response.data) {
set({
conductor: response.data,
token: savedToken,
isAuthenticated: true,
});
// Actualizar push token
const pushToken = await getPushToken();
if (pushToken && dispositivo) {
await storage.set(STORAGE_KEYS.DISPOSITIVO, {
...dispositivo,
tokenPush: pushToken,
});
}
} else {
// Token inválido, limpiar sesión
await get().logout();
}
} catch {
// Error de red, usar datos locales
set({
conductor: savedConductor,
token: savedToken,
isAuthenticated: true,
});
}
}
} catch (error) {
console.error('Error inicializando auth:', error);
} finally {
set({ isLoading: false });
}
},
// Solicita código de verificación
requestCode: async (telefono: string) => {
try {
set({ isLoading: true });
const response = await authApi.requestCode({ telefono });
if (response.success) {
return { success: true };
}
return {
success: false,
error: response.error || 'Error al enviar código',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error de conexión';
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Verifica el código y autentica
verifyCode: async (telefono: string, codigo: string) => {
try {
set({ isLoading: true });
const dispositivo = get().dispositivo || (await getDeviceInfo());
// Obtener push token
const pushToken = await getPushToken();
if (pushToken) {
dispositivo.tokenPush = pushToken;
}
const response = await authApi.verifyCode({
telefono,
codigo,
dispositivo,
});
if (response.success && response.data) {
const { conductor, token } = response.data;
// Guardar en storage
await storage.set(STORAGE_KEYS.AUTH_TOKEN, token);
await storage.set(STORAGE_KEYS.CONDUCTOR, conductor);
await storage.set(STORAGE_KEYS.DISPOSITIVO, dispositivo);
set({
conductor,
token,
dispositivo,
isAuthenticated: true,
});
return { success: true };
}
return {
success: false,
error: response.error || 'Código inválido',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error de verificación';
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Cierra sesión
logout: async () => {
try {
set({ isLoading: true });
// Intentar cerrar sesión en el servidor
try {
await authApi.logout();
} catch {
// Ignorar errores de red
}
// Limpiar storage local
await storage.removeMultiple([
STORAGE_KEYS.AUTH_TOKEN,
STORAGE_KEYS.CONDUCTOR,
STORAGE_KEYS.VIAJE_ACTIVO,
]);
set({
conductor: null,
token: null,
isAuthenticated: false,
});
} catch (error) {
console.error('Error en logout:', error);
} finally {
set({ isLoading: false });
}
},
// Refresca el token
refreshToken: async () => {
try {
const response = await authApi.refreshToken();
if (response.success && response.data) {
const { token } = response.data;
await storage.set(STORAGE_KEYS.AUTH_TOKEN, token);
set({ token });
return true;
}
return false;
} catch {
return false;
}
},
// Actualiza datos del conductor
updateConductor: (updates: Partial<Conductor>) => {
const current = get().conductor;
if (current) {
const updated = { ...current, ...updates };
set({ conductor: updated });
storage.set(STORAGE_KEYS.CONDUCTOR, updated);
}
},
// Actualiza push token
updatePushToken: async (token: string) => {
const dispositivo = get().dispositivo;
if (dispositivo) {
const updated = { ...dispositivo, tokenPush: token };
set({ dispositivo: updated });
await storage.set(STORAGE_KEYS.DISPOSITIVO, updated);
}
},
// Establece estado de carga
setLoading: (loading: boolean) => {
set({ isLoading: loading });
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
partialState: (state) => ({
conductor: state.conductor,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
export default useAuthStore;

11
mobile/src/store/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Exportación centralizada de stores
*/
export { useAuthStore, default as authStore } from './authStore';
export { useViajeStore, default as viajeStore } from './viajeStore';
export {
useUbicacionStore,
initializeConnectionMonitoring,
default as ubicacionStore,
} from './ubicacionStore';

View File

@@ -0,0 +1,206 @@
/**
* Store de ubicaciones usando Zustand
* Gestiona la cola de ubicaciones offline y sincronización
*/
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import {
syncOfflineLocations,
getPendingLocationsCount,
getCurrentLocation,
} from '../services/location';
import type { Ubicacion, UbicacionOffline } from '../types';
interface UbicacionState {
// Estado
ubicacionActual: Ubicacion | null;
ubicacionesOffline: UbicacionOffline[];
isOnline: boolean;
isSyncing: boolean;
ultimaSync: number | null;
pendingCount: number;
}
interface UbicacionStore extends UbicacionState {
// Acciones
setUbicacionActual: (ubicacion: Ubicacion) => void;
addUbicacionOffline: (ubicacion: UbicacionOffline) => void;
syncPendingLocations: () => Promise<number>;
updateConnectionStatus: (isOnline: boolean) => void;
refreshPendingCount: () => Promise<void>;
fetchCurrentLocation: () => Promise<Ubicacion | null>;
clearOfflineQueue: () => void;
reset: () => void;
}
const initialState: UbicacionState = {
ubicacionActual: null,
ubicacionesOffline: [],
isOnline: true,
isSyncing: false,
ultimaSync: null,
pendingCount: 0,
};
// Intervalo de sincronización automática (30 segundos)
const SYNC_INTERVAL = 30000;
let syncIntervalId: NodeJS.Timeout | null = null;
export const useUbicacionStore = create<UbicacionStore>()(
persist(
(set, get) => ({
...initialState,
// Establece la ubicación actual
setUbicacionActual: (ubicacion: Ubicacion) => {
set({ ubicacionActual: ubicacion });
},
// Añade una ubicación a la cola offline
addUbicacionOffline: (ubicacion: UbicacionOffline) => {
const { ubicacionesOffline } = get();
// Limitar a 1000 ubicaciones en cola
const maxQueue = 1000;
let nuevaCola = [...ubicacionesOffline, ubicacion];
if (nuevaCola.length > maxQueue) {
// Eliminar las más antiguas que ya estén sincronizadas
nuevaCola = nuevaCola
.filter((u) => !u.sincronizada)
.slice(-maxQueue);
}
set({
ubicacionesOffline: nuevaCola,
pendingCount: nuevaCola.filter((u) => !u.sincronizada).length,
});
},
// Sincroniza ubicaciones pendientes
syncPendingLocations: async () => {
const { isOnline, isSyncing } = get();
if (!isOnline || isSyncing) {
return 0;
}
try {
set({ isSyncing: true });
const sincronizadas = await syncOfflineLocations();
if (sincronizadas > 0) {
// Actualizar cola local
const { ubicacionesOffline } = get();
const actualizadas = ubicacionesOffline.filter((u) => !u.sincronizada);
set({
ubicacionesOffline: actualizadas,
ultimaSync: Date.now(),
pendingCount: actualizadas.length,
});
}
return sincronizadas;
} catch (error) {
console.error('Error en sincronización:', error);
return 0;
} finally {
set({ isSyncing: false });
}
},
// Actualiza estado de conexión
updateConnectionStatus: (isOnline: boolean) => {
const wasOffline = !get().isOnline;
set({ isOnline });
// Si volvimos a estar online, intentar sincronizar
if (wasOffline && isOnline) {
get().syncPendingLocations();
}
},
// Actualiza conteo de ubicaciones pendientes
refreshPendingCount: async () => {
try {
const count = await getPendingLocationsCount();
set({ pendingCount: count });
} catch (error) {
console.error('Error actualizando conteo:', error);
}
},
// Obtiene la ubicación actual
fetchCurrentLocation: async () => {
try {
const ubicacion = await getCurrentLocation();
if (ubicacion) {
set({ ubicacionActual: ubicacion });
}
return ubicacion;
} catch (error) {
console.error('Error obteniendo ubicación:', error);
return null;
}
},
// Limpia la cola offline
clearOfflineQueue: () => {
set({
ubicacionesOffline: [],
pendingCount: 0,
});
},
// Reinicia el store
reset: () => {
set(initialState);
},
}),
{
name: 'ubicacion-storage',
storage: createJSONStorage(() => AsyncStorage),
partialState: (state) => ({
ubicacionesOffline: state.ubicacionesOffline,
ultimaSync: state.ultimaSync,
}),
}
)
);
// Inicializa monitoreo de conexión
export const initializeConnectionMonitoring = (): (() => void) => {
const store = useUbicacionStore.getState();
// Listener de estado de conexión
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
store.updateConnectionStatus(state.isConnected ?? false);
});
// Sincronización periódica
syncIntervalId = setInterval(() => {
const { isOnline, pendingCount } = useUbicacionStore.getState();
if (isOnline && pendingCount > 0) {
useUbicacionStore.getState().syncPendingLocations();
}
}, SYNC_INTERVAL);
// Función de limpieza
return () => {
unsubscribe();
if (syncIntervalId) {
clearInterval(syncIntervalId);
syncIntervalId = null;
}
};
};
export default useUbicacionStore;

View File

@@ -0,0 +1,494 @@
/**
* Store de viajes usando Zustand
* Gestiona el viaje activo, paradas y estado del tracking
*/
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { viajesApi, paradasApi } from '../services/api';
import { storage, STORAGE_KEYS } from '../services/storage';
import {
startLocationTracking,
stopLocationTracking,
getCurrentLocation,
} from '../services/location';
import type { Viaje, Parada, Ubicacion, TipoParada, EstadoViaje } from '../types';
interface ViajeState {
// Estado
viajeActivo: Viaje | null;
proximoViaje: Viaje | null;
paradaActual: Parada | null;
isTracking: boolean;
isLoading: boolean;
error: string | null;
// Estadísticas del día
viajesHoy: number;
distanciaHoy: number;
tiempoConduccionHoy: number;
}
interface ViajeStore extends ViajeState {
// Acciones de viaje
fetchViajeActivo: () => Promise<void>;
fetchProximoViaje: () => Promise<void>;
iniciarViaje: (viajeId: string) => Promise<{ success: boolean; error?: string }>;
pausarViaje: (motivo?: string) => Promise<{ success: boolean; error?: string }>;
reanudarViaje: () => Promise<{ success: boolean; error?: string }>;
finalizarViaje: (notas?: string) => Promise<{ success: boolean; error?: string }>;
// Acciones de paradas
registrarLlegadaParada: (paradaId: string) => Promise<{ success: boolean; error?: string }>;
registrarSalidaParada: (
paradaId: string,
notas?: string
) => Promise<{ success: boolean; error?: string }>;
registrarParadaNoProgramada: (data: {
tipo: TipoParada;
notas?: string;
}) => Promise<{ success: boolean; error?: string }>;
// Utilidades
setViajeActivo: (viaje: Viaje | null) => void;
updateEstadisticasDia: (stats: Partial<ViajeState>) => void;
clearError: () => void;
reset: () => void;
}
const initialState: ViajeState = {
viajeActivo: null,
proximoViaje: null,
paradaActual: null,
isTracking: false,
isLoading: false,
error: null,
viajesHoy: 0,
distanciaHoy: 0,
tiempoConduccionHoy: 0,
};
export const useViajeStore = create<ViajeStore>()(
persist(
(set, get) => ({
...initialState,
// Obtiene el viaje activo del servidor
fetchViajeActivo: async () => {
try {
set({ isLoading: true, error: null });
const response = await viajesApi.getViajeActivo();
if (response.success) {
const viaje = response.data;
if (viaje) {
// Encontrar parada actual (primera no completada)
const paradaActual =
viaje.paradas.find((p) => !p.completada && p.orden === 1) ||
viaje.paradas.find((p) => !p.completada) ||
null;
// Si hay viaje en curso, asegurar que el tracking está activo
if (viaje.estado === 'en_curso') {
await startLocationTracking(viaje.id);
set({ isTracking: true });
}
set({ viajeActivo: viaje, paradaActual });
// Guardar localmente para modo offline
await storage.set(STORAGE_KEYS.VIAJE_ACTIVO, viaje);
} else {
set({ viajeActivo: null, paradaActual: null });
}
}
} catch (error) {
// Intentar cargar desde storage local
const viajeLocal = await storage.get<Viaje>(STORAGE_KEYS.VIAJE_ACTIVO);
if (viajeLocal) {
set({ viajeActivo: viajeLocal });
}
set({
error: error instanceof Error ? error.message : 'Error al obtener viaje',
});
} finally {
set({ isLoading: false });
}
},
// Obtiene el próximo viaje asignado
fetchProximoViaje: async () => {
try {
const response = await viajesApi.getProximoViaje();
if (response.success) {
set({ proximoViaje: response.data });
}
} catch (error) {
console.error('Error obteniendo próximo viaje:', error);
}
},
// Inicia un viaje
iniciarViaje: async (viajeId: string) => {
try {
set({ isLoading: true, error: null });
const response = await viajesApi.iniciarViaje(viajeId);
if (response.success && response.data) {
const viaje = response.data;
// Iniciar tracking de ubicación
await startLocationTracking(viaje.id);
const paradaActual =
viaje.paradas.find((p) => !p.completada) || null;
set({
viajeActivo: viaje,
paradaActual,
isTracking: true,
proximoViaje: null,
});
await storage.set(STORAGE_KEYS.VIAJE_ACTIVO, viaje);
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo iniciar el viaje',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al iniciar viaje';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Pausa el viaje activo
pausarViaje: async (motivo?: string) => {
const { viajeActivo } = get();
if (!viajeActivo) {
return { success: false, error: 'No hay viaje activo' };
}
try {
set({ isLoading: true, error: null });
const response = await viajesApi.pausarViaje(viajeActivo.id, motivo);
if (response.success && response.data) {
// Detener tracking
await stopLocationTracking();
set({
viajeActivo: response.data,
isTracking: false,
});
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo pausar el viaje',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al pausar viaje';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Reanuda el viaje pausado
reanudarViaje: async () => {
const { viajeActivo } = get();
if (!viajeActivo) {
return { success: false, error: 'No hay viaje activo' };
}
try {
set({ isLoading: true, error: null });
const response = await viajesApi.reanudarViaje(viajeActivo.id);
if (response.success && response.data) {
// Reanudar tracking
await startLocationTracking(response.data.id);
set({
viajeActivo: response.data,
isTracking: true,
});
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo reanudar el viaje',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al reanudar viaje';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Finaliza el viaje activo
finalizarViaje: async (notas?: string) => {
const { viajeActivo } = get();
if (!viajeActivo) {
return { success: false, error: 'No hay viaje activo' };
}
try {
set({ isLoading: true, error: null });
const response = await viajesApi.finalizarViaje(viajeActivo.id, notas);
if (response.success) {
// Detener tracking
await stopLocationTracking();
// Actualizar estadísticas del día
const state = get();
set({
viajeActivo: null,
paradaActual: null,
isTracking: false,
viajesHoy: state.viajesHoy + 1,
distanciaHoy: state.distanciaHoy + (viajeActivo.distanciaRecorrida || 0),
tiempoConduccionHoy:
state.tiempoConduccionHoy + (viajeActivo.tiempoTranscurrido || 0),
});
await storage.remove(STORAGE_KEYS.VIAJE_ACTIVO);
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo finalizar el viaje',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al finalizar viaje';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Registra llegada a una parada
registrarLlegadaParada: async (paradaId: string) => {
try {
set({ isLoading: true, error: null });
const ubicacion = await getCurrentLocation();
if (!ubicacion) {
return { success: false, error: 'No se pudo obtener la ubicación' };
}
const response = await paradasApi.registrarLlegada(paradaId, ubicacion);
if (response.success && response.data) {
const { viajeActivo } = get();
if (viajeActivo) {
// Actualizar parada en el viaje
const paradasActualizadas = viajeActivo.paradas.map((p) =>
p.id === paradaId ? response.data! : p
);
const viajeActualizado = {
...viajeActivo,
paradas: paradasActualizadas,
};
set({
viajeActivo: viajeActualizado,
paradaActual: response.data,
});
}
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo registrar la llegada',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al registrar llegada';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Registra salida de una parada
registrarSalidaParada: async (paradaId: string, notas?: string) => {
try {
set({ isLoading: true, error: null });
const response = await paradasApi.registrarSalida(paradaId, notas);
if (response.success && response.data) {
const { viajeActivo } = get();
if (viajeActivo) {
// Actualizar parada y encontrar la siguiente
const paradasActualizadas = viajeActivo.paradas.map((p) =>
p.id === paradaId ? { ...response.data!, completada: true } : p
);
const siguienteParada =
paradasActualizadas.find((p) => !p.completada) || null;
const viajeActualizado = {
...viajeActivo,
paradas: paradasActualizadas,
};
set({
viajeActivo: viajeActualizado,
paradaActual: siguienteParada,
});
}
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo registrar la salida',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al registrar salida';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Registra una parada no programada
registrarParadaNoProgramada: async (data: {
tipo: TipoParada;
notas?: string;
}) => {
const { viajeActivo } = get();
if (!viajeActivo) {
return { success: false, error: 'No hay viaje activo' };
}
try {
set({ isLoading: true, error: null });
const ubicacion = await getCurrentLocation();
if (!ubicacion) {
return { success: false, error: 'No se pudo obtener la ubicación' };
}
const response = await paradasApi.registrarParadaNoProgramada({
viajeId: viajeActivo.id,
tipo: data.tipo,
ubicacion,
notas: data.notas,
});
if (response.success && response.data) {
// Añadir parada al viaje
const viajeActualizado = {
...viajeActivo,
paradas: [...viajeActivo.paradas, response.data],
};
set({ viajeActivo: viajeActualizado });
return { success: true };
}
return {
success: false,
error: response.error || 'No se pudo registrar la parada',
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Error al registrar parada';
set({ error: message });
return { success: false, error: message };
} finally {
set({ isLoading: false });
}
},
// Establece viaje activo manualmente
setViajeActivo: (viaje: Viaje | null) => {
set({ viajeActivo: viaje });
if (viaje) {
storage.set(STORAGE_KEYS.VIAJE_ACTIVO, viaje);
} else {
storage.remove(STORAGE_KEYS.VIAJE_ACTIVO);
}
},
// Actualiza estadísticas del día
updateEstadisticasDia: (stats: Partial<ViajeState>) => {
set(stats);
},
// Limpia error
clearError: () => {
set({ error: null });
},
// Reinicia el store
reset: () => {
stopLocationTracking();
set(initialState);
},
}),
{
name: 'viaje-storage',
storage: createJSONStorage(() => AsyncStorage),
partialState: (state) => ({
viajeActivo: state.viajeActivo,
viajesHoy: state.viajesHoy,
distanciaHoy: state.distanciaHoy,
tiempoConduccionHoy: state.tiempoConduccionHoy,
}),
}
)
);
export default useViajeStore;

300
mobile/src/types/index.ts Normal file
View File

@@ -0,0 +1,300 @@
// ==========================================
// TIPOS BASE Y ENUMS
// ==========================================
export type TipoParada =
| 'carga'
| 'descarga'
| 'descanso'
| 'combustible'
| 'mecanico'
| 'otro';
export type EstadoViaje =
| 'pendiente'
| 'en_curso'
| 'pausado'
| 'completado'
| 'cancelado';
export type EstadoConductor =
| 'disponible'
| 'en_viaje'
| 'en_descanso'
| 'no_disponible';
export type TipoMensaje =
| 'texto'
| 'alerta'
| 'instruccion'
| 'emergencia';
export type TipoEmergencia =
| 'accidente'
| 'robo'
| 'mecanico'
| 'salud'
| 'otro';
// ==========================================
// INTERFACES PRINCIPALES
// ==========================================
export interface Ubicacion {
latitud: number;
longitud: number;
altitud?: number;
velocidad?: number;
precision?: number;
rumbo?: number;
timestamp: number;
}
export interface UbicacionOffline extends Ubicacion {
id: string;
viajeId?: string;
sincronizada: boolean;
}
export interface Conductor {
id: string;
nombre: string;
apellido: string;
telefono: string;
email?: string;
licencia: string;
foto?: string;
estado: EstadoConductor;
vehiculoAsignado?: Vehiculo;
calificacion: number;
fechaRegistro: string;
ultimaActividad?: string;
}
export interface Vehiculo {
id: string;
placa: string;
marca: string;
modelo: string;
anio: number;
tipo: string;
capacidadCarga: number;
unidadCarga: string;
odometro: number;
combustible: TipoCombustible;
}
export type TipoCombustible = 'gasolina' | 'diesel' | 'gas' | 'electrico' | 'hibrido';
export interface Viaje {
id: string;
conductorId: string;
vehiculoId: string;
estado: EstadoViaje;
origen: Punto;
destino: Punto;
paradas: Parada[];
distanciaEstimada: number;
distanciaRecorrida: number;
tiempoEstimado: number; // minutos
tiempoTranscurrido: number; // minutos
fechaInicio?: string;
fechaFin?: string;
notas?: string;
cliente?: string;
carga?: InfoCarga;
}
export interface Punto {
nombre: string;
direccion: string;
latitud: number;
longitud: number;
}
export interface Parada {
id: string;
viajeId: string;
tipo: TipoParada;
punto: Punto;
orden: number;
programada: boolean;
horaLlegada?: string;
horaSalida?: string;
duracion?: number;
notas?: string;
fotos?: string[];
completada: boolean;
}
export interface InfoCarga {
descripcion: string;
peso?: number;
unidad?: string;
valor?: number;
documentos?: string[];
}
export interface CargaCombustible {
id: string;
viajeId?: string;
conductorId: string;
vehiculoId: string;
fecha: string;
litros: number;
precioLitro: number;
total: number;
odometro: number;
estacion: string;
tipoCombustible: TipoCombustible;
comprobante?: string;
ubicacion?: Ubicacion;
}
export interface Mensaje {
id: string;
conductorId: string;
tipo: TipoMensaje;
contenido: string;
remitente: 'conductor' | 'admin';
leido: boolean;
fechaEnvio: string;
fechaLectura?: string;
}
export interface Emergencia {
id: string;
conductorId: string;
viajeId?: string;
tipo: TipoEmergencia;
descripcion?: string;
ubicacion: Ubicacion;
fecha: string;
atendida: boolean;
respuesta?: string;
}
export interface EstadisticasDia {
fecha: string;
viajesCompletados: number;
distanciaTotal: number;
tiempoConduccion: number;
paradasRealizadas: number;
combustibleUsado: number;
incidentes: number;
}
export interface EstadisticasConductor {
totalViajes: number;
distanciaTotal: number;
horasConduccion: number;
calificacionPromedio: number;
viajesEsteMes: number;
distanciaEsteMes: number;
}
// ==========================================
// INTERFACES DE AUTENTICACION
// ==========================================
export interface Dispositivo {
id: string;
plataforma: 'ios' | 'android';
modelo: string;
versionApp: string;
tokenPush?: string;
}
export interface AuthState {
conductor: Conductor | null;
token: string | null;
dispositivo: Dispositivo | null;
isAuthenticated: boolean;
isLoading: boolean;
}
export interface LoginRequest {
telefono: string;
}
export interface VerifyCodeRequest {
telefono: string;
codigo: string;
dispositivo: Dispositivo;
}
export interface AuthResponse {
conductor: Conductor;
token: string;
expiraEn: number;
}
// ==========================================
// INTERFACES DE RESPUESTA API
// ==========================================
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
pagina: number;
porPagina: number;
totalPaginas: number;
}
// ==========================================
// INTERFACES DE NAVEGACION
// ==========================================
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
export type AuthStackParamList = {
Login: undefined;
VerifyCode: { telefono: string };
};
export type MainTabParamList = {
Home: undefined;
Viaje: undefined;
Mensajes: undefined;
Perfil: undefined;
};
export type ViajeStackParamList = {
ViajeActivo: undefined;
RegistrarParada: { paradaId?: string };
Combustible: undefined;
Emergencia: undefined;
Camara: undefined;
};
// ==========================================
// INTERFACES DE CONFIGURACION
// ==========================================
export interface ConfiguracionApp {
intervaloUbicacion: number; // segundos
tiempoSincronizacion: number; // segundos
modoOscuro: boolean;
notificacionesSonido: boolean;
notificacionesVibracion: boolean;
calidadVideo: 'baja' | 'media' | 'alta';
autoDetectarParadas: boolean;
velocidadParada: number; // km/h para detectar parada
}
export interface PermisoStatus {
ubicacion: 'granted' | 'denied' | 'undetermined';
ubicacionBackground: 'granted' | 'denied' | 'undetermined';
camara: 'granted' | 'denied' | 'undetermined';
notificaciones: 'granted' | 'denied' | 'undetermined';
}

235
mobile/src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,235 @@
/**
* Funciones auxiliares de utilidad
*/
/**
* Formatea distancia en km o m según el valor
*/
export const formatDistance = (km: number): string => {
if (km < 1) {
return `${Math.round(km * 1000)} m`;
}
return `${km.toFixed(1)} km`;
};
/**
* Formatea tiempo en horas y minutos
*/
export const formatDuration = (minutes: number): string => {
if (minutes < 60) {
return `${Math.round(minutes)} min`;
}
const hours = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}min`;
};
/**
* Formatea hora en formato legible
*/
export const formatTime = (date: Date | string): string => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
});
};
/**
* Formatea fecha en formato legible
*/
export const formatDate = (date: Date | string): string => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('es-MX', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
};
/**
* Formatea fecha y hora completa
*/
export const formatDateTime = (date: Date | string): string => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('es-MX', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
};
/**
* Formatea velocidad en km/h
*/
export const formatSpeed = (kmh: number): string => {
return `${Math.round(kmh)} km/h`;
};
/**
* Formatea moneda
*/
export const formatCurrency = (amount: number, currency = 'MXN'): string => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
}).format(amount);
};
/**
* Formatea número de teléfono
*/
export const formatPhone = (phone: string): string => {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
return phone;
};
/**
* Valida número de teléfono mexicano
*/
export const isValidPhone = (phone: string): boolean => {
const cleaned = phone.replace(/\D/g, '');
return cleaned.length === 10 && /^[1-9]\d{9}$/.test(cleaned);
};
/**
* Trunca texto con elipsis
*/
export const truncate = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
};
/**
* Capitaliza primera letra
*/
export const capitalize = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
};
/**
* Genera ID único
*/
export const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Debounce function
*/
export const debounce = <T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
}, wait);
};
};
/**
* Throttle function
*/
export const throttle = <T extends (...args: unknown[]) => unknown>(
func: T,
limit: number
): ((...args: Parameters<T>) => void) => {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
};
/**
* Sleep/delay
*/
export const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
/**
* Obtiene iniciales de un nombre
*/
export const getInitials = (name: string): string => {
return name
.split(' ')
.map((n) => n.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
};
/**
* Calcula tiempo transcurrido en formato legible
*/
export const timeAgo = (date: Date | string): string => {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const seconds = Math.floor((now.getTime() - d.getTime()) / 1000);
if (seconds < 60) return 'hace un momento';
if (seconds < 3600) return `hace ${Math.floor(seconds / 60)} min`;
if (seconds < 86400) return `hace ${Math.floor(seconds / 3600)} h`;
if (seconds < 604800) return `hace ${Math.floor(seconds / 86400)} días`;
return formatDate(d);
};
/**
* Mapea tipo de parada a label legible
*/
export const getParadaLabel = (tipo: string): string => {
const labels: Record<string, string> = {
carga: 'Carga',
descarga: 'Descarga',
descanso: 'Descanso',
combustible: 'Combustible',
mecanico: 'Servicio mecánico',
otro: 'Otro',
};
return labels[tipo] || tipo;
};
/**
* Mapea estado de viaje a label y color
*/
export const getEstadoViajeInfo = (
estado: string
): { label: string; color: string } => {
const estados: Record<string, { label: string; color: string }> = {
pendiente: { label: 'Pendiente', color: '#f59e0b' },
en_curso: { label: 'En curso', color: '#3b82f6' },
pausado: { label: 'Pausado', color: '#6b7280' },
completado: { label: 'Completado', color: '#22c55e' },
cancelado: { label: 'Cancelado', color: '#ef4444' },
};
return estados[estado] || { label: estado, color: '#6b7280' };
};

View File

@@ -0,0 +1,8 @@
/**
* Exportación centralizada de utilidades
*/
export * from './theme';
export * from './helpers';
export { default as theme } from './theme';

169
mobile/src/utils/theme.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* Tema y constantes de diseño de la aplicación
* Optimizado para uso en exterior (tema claro)
*/
export const COLORS = {
// Colores principales
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryLight: '#60a5fa',
// Estado
success: '#22c55e',
successDark: '#16a34a',
successLight: '#4ade80',
danger: '#ef4444',
dangerDark: '#dc2626',
dangerLight: '#f87171',
warning: '#f59e0b',
warningDark: '#d97706',
warningLight: '#fbbf24',
info: '#06b6d4',
infoDark: '#0891b2',
infoLight: '#22d3ee',
// Neutrales
white: '#ffffff',
black: '#000000',
gray50: '#f9fafb',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray500: '#6b7280',
gray600: '#4b5563',
gray700: '#374151',
gray800: '#1f2937',
gray900: '#111827',
// Fondos
background: '#f9fafb',
surface: '#ffffff',
surfaceSecondary: '#f3f4f6',
// Texto
textPrimary: '#111827',
textSecondary: '#4b5563',
textTertiary: '#9ca3af',
textInverse: '#ffffff',
// Bordes
border: '#e5e7eb',
borderLight: '#f3f4f6',
borderDark: '#d1d5db',
// Overlays
overlay: 'rgba(0, 0, 0, 0.5)',
overlayLight: 'rgba(0, 0, 0, 0.3)',
} as const;
export const SPACING = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
export const FONT_SIZE = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20,
xxl: 24,
xxxl: 32,
display: 40,
} as const;
export const FONT_WEIGHT = {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
};
export const BORDER_RADIUS = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
xxl: 24,
full: 9999,
} as const;
export const SHADOWS = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
} as const;
// Tamaños de botones grandes para uso mientras conduce
export const BUTTON_SIZES = {
sm: {
height: 40,
paddingHorizontal: 16,
fontSize: 14,
},
md: {
height: 48,
paddingHorizontal: 20,
fontSize: 16,
},
lg: {
height: 56,
paddingHorizontal: 24,
fontSize: 18,
},
xl: {
height: 64,
paddingHorizontal: 32,
fontSize: 20,
},
} as const;
// Tamaños de iconos
export const ICON_SIZES = {
sm: 16,
md: 24,
lg: 32,
xl: 48,
xxl: 64,
} as const;
export const theme = {
colors: COLORS,
spacing: SPACING,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
borderRadius: BORDER_RADIUS,
shadows: SHADOWS,
buttonSizes: BUTTON_SIZES,
iconSizes: ICON_SIZES,
};
export default theme;

33
mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-native",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@screens/*": ["src/screens/*"],
"@services/*": ["src/services/*"],
"@hooks/*": ["src/hooks/*"],
"@store/*": ["src/store/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
},
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*", "App.tsx"],
"exclude": ["node_modules", "babel.config.js", "metro.config.js"]
}