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:
50
mobile/.gitignore
vendored
Normal file
50
mobile/.gitignore
vendored
Normal 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
6
mobile/App.tsx
Normal 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
86
mobile/app.json
Normal 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
26
mobile/babel.config.js
Normal 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
43
mobile/package.json
Normal 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
362
mobile/src/App.tsx
Normal 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;
|
||||
156
mobile/src/components/Button.tsx
Normal file
156
mobile/src/components/Button.tsx
Normal 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;
|
||||
155
mobile/src/components/Input.tsx
Normal file
155
mobile/src/components/Input.tsx
Normal 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;
|
||||
146
mobile/src/components/LoadingOverlay.tsx
Normal file
146
mobile/src/components/LoadingOverlay.tsx
Normal 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;
|
||||
281
mobile/src/components/MapView.tsx
Normal file
281
mobile/src/components/MapView.tsx
Normal 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;
|
||||
169
mobile/src/components/StatCard.tsx
Normal file
169
mobile/src/components/StatCard.tsx
Normal 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;
|
||||
281
mobile/src/components/ViajeCard.tsx
Normal file
281
mobile/src/components/ViajeCard.tsx
Normal 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;
|
||||
15
mobile/src/components/index.ts
Normal file
15
mobile/src/components/index.ts
Normal 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';
|
||||
7
mobile/src/hooks/index.ts
Normal file
7
mobile/src/hooks/index.ts
Normal 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';
|
||||
82
mobile/src/hooks/useAuth.ts
Normal file
82
mobile/src/hooks/useAuth.ts
Normal 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;
|
||||
243
mobile/src/hooks/useLocation.ts
Normal file
243
mobile/src/hooks/useLocation.ts
Normal 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;
|
||||
232
mobile/src/hooks/useViaje.ts
Normal file
232
mobile/src/hooks/useViaje.ts
Normal 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;
|
||||
357
mobile/src/screens/Camara.tsx
Normal file
357
mobile/src/screens/Camara.tsx
Normal 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;
|
||||
389
mobile/src/screens/Combustible.tsx
Normal file
389
mobile/src/screens/Combustible.tsx
Normal 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;
|
||||
546
mobile/src/screens/Emergencia.tsx
Normal file
546
mobile/src/screens/Emergencia.tsx
Normal 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
443
mobile/src/screens/Home.tsx
Normal 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;
|
||||
376
mobile/src/screens/Login.tsx
Normal file
376
mobile/src/screens/Login.tsx
Normal 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;
|
||||
430
mobile/src/screens/Mensajes.tsx
Normal file
430
mobile/src/screens/Mensajes.tsx
Normal 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;
|
||||
563
mobile/src/screens/Perfil.tsx
Normal file
563
mobile/src/screens/Perfil.tsx
Normal 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;
|
||||
346
mobile/src/screens/RegistrarParada.tsx
Normal file
346
mobile/src/screens/RegistrarParada.tsx
Normal 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;
|
||||
634
mobile/src/screens/ViajeActivo.tsx
Normal file
634
mobile/src/screens/ViajeActivo.tsx
Normal 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;
|
||||
13
mobile/src/screens/index.ts
Normal file
13
mobile/src/screens/index.ts
Normal 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
477
mobile/src/services/api.ts
Normal 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;
|
||||
13
mobile/src/services/index.ts
Normal file
13
mobile/src/services/index.ts
Normal 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';
|
||||
404
mobile/src/services/location.ts
Normal file
404
mobile/src/services/location.ts
Normal 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,
|
||||
};
|
||||
415
mobile/src/services/notifications.ts
Normal file
415
mobile/src/services/notifications.ts
Normal 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,
|
||||
};
|
||||
207
mobile/src/services/storage.ts
Normal file
207
mobile/src/services/storage.ts
Normal 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;
|
||||
281
mobile/src/store/authStore.ts
Normal file
281
mobile/src/store/authStore.ts
Normal 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
11
mobile/src/store/index.ts
Normal 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';
|
||||
206
mobile/src/store/ubicacionStore.ts
Normal file
206
mobile/src/store/ubicacionStore.ts
Normal 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;
|
||||
494
mobile/src/store/viajeStore.ts
Normal file
494
mobile/src/store/viajeStore.ts
Normal 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
300
mobile/src/types/index.ts
Normal 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
235
mobile/src/utils/helpers.ts
Normal 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' };
|
||||
};
|
||||
8
mobile/src/utils/index.ts
Normal file
8
mobile/src/utils/index.ts
Normal 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
169
mobile/src/utils/theme.ts
Normal 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
33
mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user