diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b698742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/ios +/android + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# expo +.expo/ \ No newline at end of file diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..23923ad --- /dev/null +++ b/App.tsx @@ -0,0 +1,134 @@ +"use client" + +import "react-native-gesture-handler" +import React, { useEffect, useState } from "react" +import { NavigationContainer } from "@react-navigation/native" +import { StatusBar } from "expo-status-bar" +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context" +import * as SplashScreen from "expo-splash-screen" +import NetInfo from "@react-native-community/netinfo" +import { AuthProvider } from "./src/contexts/AuthContext" +import { SyncProvider } from "./src/contexts/SyncContext" +import { DeliveriesProvider } from "./src/contexts/DeliveriesContext" +import { OfflineProvider } from "./src/contexts/OfflineContext" +import { OfflineModeProvider } from "./src/contexts/OfflineModeContext" +import Navigation, { navigationRef } from "./src/navigation" +import { registerForPushNotificationsAsync } from "./src/services/notifications" +import { setupDatabase, storageInfo } from "./src/services/database" +import { I18nManager, Text, View, Platform } from "react-native" +import { COLORS } from "./src/constants/theme" +import FloatingPanicButton from './components/FloatingPanicButton' +import { GestureHandlerRootView } from 'react-native-gesture-handler' + +// Forçar LTR (Left-to-Right) para evitar problemas com idiomas RTL +if (I18nManager.isRTL) { + I18nManager.allowRTL(false) + I18nManager.forceRTL(false) +} + +// Garantir que o Text padrão não use fontes estranhas +(Text as any).defaultProps = (Text as any).defaultProps || {}; +(Text as any).defaultProps.allowFontScaling = false; + +// Keep the splash screen visible while we fetch resources +SplashScreen.preventAutoHideAsync() + +export default function App() { + const [appIsReady, setAppIsReady] = useState(false) + const [isConnected, setIsConnected] = useState(true) + const [dbInitError, setDbInitError] = useState(null) + + useEffect(() => { + async function prepare() { + try { + // Inicializar banco de dados + await setupDatabase() + console.log(`Banco de dados inicializado com sucesso usando ${storageInfo.type}`) + + // Registrar para notificações push + await registerForPushNotificationsAsync() + + // Atraso artificial para uma tela de splash mais suave + await new Promise((resolve) => setTimeout(resolve, 1000)) + } catch (e) { + console.warn("Erro ao carregar recursos:", e) + let msg = 'Erro desconhecido' + if (e instanceof Error) msg = e.message + setDbInitError(`Erro ao inicializar: ${msg}`) + } finally { + setAppIsReady(true) + } + } + + prepare() + }, []) + + useEffect(() => { + // Inscrever-se para atualizações de estado da rede + const unsubscribe = NetInfo.addEventListener((state) => { + setIsConnected(state.isConnected !== null ? state.isConnected : false) + }) + + return () => unsubscribe() + }, []) + + const onLayoutRootView = React.useCallback(async () => { + if (appIsReady) { + await SplashScreen.hideAsync() + } + }, [appIsReady]) + + const handlePanic = (location?: { latitude: number; longitude: number } | null) => { + if (location && location.latitude && location.longitude) { + alert(`Pânico acionado! Sua localização foi enviada para a central:\nLat: ${location.latitude}\nLng: ${location.longitude}`); + } else { + alert('Pânico acionado! Não foi possível obter sua localização.'); + } + } + + if (!appIsReady) { + return null + } + + // Se houver um erro na inicialização do banco de dados, mostrar uma tela de erro + if (dbInitError) { + return ( + + + Erro na inicialização do aplicativo + + {dbInitError} + Usando: {storageInfo.type} + + ) + } + + return ( + + + + + + + + + {Platform.OS === 'android' ? ( + + + + ) : ( + + )} + + + + + + + + {/* */} + + + ) +} + diff --git a/SISTEMA_ROTEIRIZACAO_TSP.md b/SISTEMA_ROTEIRIZACAO_TSP.md new file mode 100644 index 0000000..f0fa63b --- /dev/null +++ b/SISTEMA_ROTEIRIZACAO_TSP.md @@ -0,0 +1,1186 @@ +# 🚚 **SISTEMA DE ROTEIRIZAÇÃO TSP - EXPO REACT NATIVE** + +## **📋 VISÃO GERAL** + +Sistema completo de otimização de rotas de entrega implementando o algoritmo **Traveling Salesman Problem (TSP)** usando **Nearest Neighbor** para organizar entregas pela distância mais eficiente. + +**URL Base da API:** `https://api.entrega.homologacao.jurunense.com` + +--- + +## **🏗️ ARQUITETURA DO SISTEMA** + +### **Estrutura de Arquivos** +``` +src/ +├── screens/ +│ └── main/ +│ └── RoutingScreen.tsx # Tela principal de roteirização +├── services/ +│ └── api.ts # Serviços de API e algoritmos TSP +├── types/ +│ └── index.ts # Tipos TypeScript +├── contexts/ +│ └── AuthContext.tsx # Contexto de autenticação +└── config/ + └── env.ts # Configurações de ambiente +``` + +### **Componentes Principais** +- **RoutingScreen**: Interface para execução de roteirização +- **ApiService**: Classe para comunicação com API +- **Algoritmos TSP**: Funções de otimização de rota + +--- + +## **🔧 IMPLEMENTAÇÃO PASSO A PASSO** + +### **PASSO 1: CONFIGURAÇÃO DE AMBIENTE** + +```typescript +// src/config/env.ts +export const API_BASE_URL = 'https://api.entrega.homologacao.jurunense.com'; +export const AUTH_TOKEN_KEY = 'AUTH_TOKEN'; +export const USER_DATA_KEY = 'USER_DATA'; + +// Função para converter coordenadas (vírgula → ponto) +export const convertCoordinate = (coord: any): number => { + if (coord === null || coord === undefined || coord === '') { + return 0; + } + + if (typeof coord === 'number') { + return coord; + } + + if (typeof coord === 'string') { + const normalized = coord.trim().replace(',', '.'); + const parsed = parseFloat(normalized); + return isNaN(parsed) ? 0 : parsed; + } + + return 0; +}; +``` + +### **PASSO 2: TIPOS TYPESCRIPT** + +```typescript +// src/types/index.ts +export interface Delivery { + id?: string; + outId: number; + customerId: number; + customerName: string; + street: string; + streetNumber: string; + neighborhood: string; + city: string; + state: string; + zipCode: string; + lat: number | string | null; + lng: number | string | null; + coordinates?: { + latitude: number | string; + longitude: number | string; + }; + deliverySeq: number; + routing: number; // 0 = não roteirizada, 1 = roteirizada + status: 'pending' | 'in_progress' | 'delivered' | 'failed'; + outDate: string; + latFrom?: string; + lngFrom?: string; +} + +export interface RoutingData { + outId: number; + customerId: number; + deliverySeq: number; + lat: number; + lng: number; +} +``` + +### **PASSO 3: FUNÇÕES UTILITÁRIAS** + +```typescript +// src/services/api.ts + +// 1. Cálculo de distância (Fórmula de Haversine) +export const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371; // Raio da Terra em km + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = + Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon/2) * Math.sin(dLon/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; +}; + +// 2. Centro de distribuição dinâmico +export const getDistributionCenter = (deliveries: Delivery[]): { + latitude: number; + longitude: number; + address: string +} => { + const deliveryWithCoords = deliveries.find(delivery => + delivery.latFrom && delivery.lngFrom && + delivery.latFrom !== 'null' && delivery.lngFrom !== 'null' && + delivery.latFrom !== '' && delivery.lngFrom !== '' + ); + + if (deliveryWithCoords && deliveryWithCoords.latFrom && deliveryWithCoords.lngFrom) { + const lat = parseFloat(deliveryWithCoords.latFrom); + const lng = parseFloat(deliveryWithCoords.lngFrom); + + if (!isNaN(lat) && !isNaN(lng)) { + return { + latitude: lat, + longitude: lng, + address: "Centro de Distribuição (API)" + }; + } + } + + // Fallback para coordenadas padrão + return { + latitude: -1.3654, + longitude: -48.3722, + address: "Centro de Distribuição (Padrão)" + }; +}; +``` + +### **PASSO 4: ALGORITMO TSP - NEAREST NEIGHBOR** + +```typescript +// src/services/api.ts + +const nearestNeighborTSP = (distanceMatrix: number[][], deliveries: Delivery[]): Delivery[] => { + console.log('=== 🔍 EXECUTANDO ALGORITMO NEAREST NEIGHBOR ==='); + + const n = deliveries.length; + const visited = new Array(n).fill(false); + const route: Delivery[] = []; + + // Começar do centro de distribuição (índice 0 na matriz) + let currentIndex = 0; + visited[0] = true; + + // Adicionar primeira entrega (mais próxima do centro) + if (n > 0) { + route.push(deliveries[0]); + console.log(`📍 Primeira entrega: ${deliveries[0].customerName} (mais próxima do centro)`); + } + + // Encontrar o próximo ponto mais próximo + for (let step = 1; step < n; step++) { + let minDistance = Infinity; + let nextIndex = -1; + + // Procurar o ponto não visitado mais próximo + for (let i = 1; i < n; i++) { // Pular índice 0 (centro de distribuição) + if (!visited[i]) { + const distance = distanceMatrix[currentIndex][i]; + if (distance < minDistance) { + minDistance = distance; + nextIndex = i; + } + } + } + + if (nextIndex !== -1) { + visited[nextIndex] = true; + route.push(deliveries[nextIndex]); + console.log(`📍 Próxima entrega: ${deliveries[nextIndex].customerName} (distância: ${minDistance.toFixed(2)} km)`); + currentIndex = nextIndex; + } + } + + console.log(`✅ Rota otimizada criada com ${route.length} entregas`); + return route; +}; +``` + +### **PASSO 5: FUNÇÃO PRINCIPAL DE OTIMIZAÇÃO** + +```typescript +// src/services/api.ts + +export const optimizeRouteWithTSP = async (deliveries: Delivery[]): Promise => { + console.log('=== 🚚 INICIANDO OTIMIZAÇÃO DE ROTA COM ALGORITMO TSP ==='); + console.log('Total de entregas para otimizar:', deliveries.length); + + // 1. Obter centro de distribuição + const distributionCenter = getDistributionCenter(deliveries); + console.log('Centro usado:', distributionCenter); + + // 2. Preparar entregas garantindo coordenadas + const preparedDeliveries: Delivery[] = []; + for (let index = 0; index < deliveries.length; index++) { + const delivery = deliveries[index]; + + let latNum: number | null = null; + let lngNum: number | null = null; + + // Tentar usar lat/lng já existentes + if (typeof delivery.lat === 'number' && typeof delivery.lng === 'number' && + !isNaN(delivery.lat) && !isNaN(delivery.lng)) { + latNum = delivery.lat; + lngNum = delivery.lng; + } + + // Normalizar strings (vírgula → ponto) + if ((latNum === null || lngNum === null) && + (typeof delivery.lat === 'string' || typeof delivery.lng === 'string')) { + const maybeLat = convertCoordinate(delivery.lat); + const maybeLng = convertCoordinate(delivery.lng); + if (typeof maybeLat === 'number' && !isNaN(maybeLat)) latNum = maybeLat; + if (typeof maybeLng === 'number' && !isNaN(maybeLng)) lngNum = maybeLng; + } + + // Usar coordinates se existirem + if ((latNum === null || lngNum === null) && delivery.coordinates) { + const maybeLat = convertCoordinate((delivery.coordinates as any).latitude); + const maybeLng = convertCoordinate((delivery.coordinates as any).longitude); + if (typeof maybeLat === 'number' && !isNaN(maybeLat)) latNum = maybeLat; + if (typeof maybeLng === 'number' && !isNaN(maybeLng)) lngNum = maybeLng; + } + + // 4. Se ainda não tem coordenadas, tentar geocodificar + if (latNum === null || lngNum === null) { + try { + const coords = await getCoordinatesFromAddress({ + address: delivery.street || '', + addressNumber: delivery.streetNumber || '', + neighborhood: delivery.neighborhood || '', + city: delivery.city || '', + state: delivery.state || '' + }); + if (coords) { + latNum = coords.latitude; + lngNum = coords.longitude; + console.log('✅ Geolocalização obtida:', coords); + } + } catch (geoErr) { + console.warn('⚠️ Falha ao geolocalizar:', geoErr); + } + } + + preparedDeliveries.push({ + ...delivery, + lat: typeof latNum === 'number' ? latNum : delivery.lat, + lng: typeof lngNum === 'number' ? lngNum : delivery.lng, + coordinates: (typeof latNum === 'number' && typeof lngNum === 'number') + ? { latitude: latNum, longitude: lngNum } + : delivery.coordinates + }); + } + + // 3. Separar entregas com e sem coordenadas + const withCoords = preparedDeliveries.filter(d => + typeof d.lat === 'number' && typeof d.lng === 'number' && + !isNaN(d.lat as number) && !isNaN(d.lng as number) + ); + const withoutCoords = preparedDeliveries.filter(d => + !(typeof d.lat === 'number' && typeof d.lng === 'number' && + !isNaN(d.lat as number) && !isNaN(d.lng as number)) + ); + + if (withCoords.length === 0) { + console.log('❌ Nenhuma entrega com coordenadas válidas'); + return preparedDeliveries.map((d, i) => ({ ...d, deliverySeq: i + 1 })); + } + + console.log(`✅ ${withCoords.length} entregas com coordenadas válidas`); + + // 4. Calcular matriz de distâncias + const distanceMatrix: number[][] = []; + const allPoints = [distributionCenter, ...withCoords.map(d => ({ + latitude: d.lat as number, + longitude: d.lng as number + }))]; + + for (let i = 0; i < allPoints.length; i++) { + distanceMatrix[i] = []; + for (let j = 0; j < allPoints.length; j++) { + if (i === j) { + distanceMatrix[i][j] = 0; + } else { + distanceMatrix[i][j] = calculateDistance( + allPoints[i].latitude, + allPoints[i].longitude, + allPoints[j].latitude, + allPoints[j].longitude + ); + } + } + } + + // 5. Executar algoritmo TSP + const optimizedWithCoords = nearestNeighborTSP(distanceMatrix, withCoords); + + // 6. Montar rota final + const finalRoute: Delivery[] = [...optimizedWithCoords, ...withoutCoords]; + + // 7. Aplicar deliverySeq sequencialmente + const finalDeliveries = finalRoute.map((delivery, idx) => { + const newSeq = idx + 1; // Garantir que comece em 1 + console.log(`🔄 Atualizando ${delivery.customerName}: deliverySeq ${delivery.deliverySeq} → ${newSeq}`); + return { ...delivery, deliverySeq: newSeq, distance: (delivery as any).distance || 0 }; + }); + + console.log('=== 🎯 ENTREGAS COM DELIVERYSEQ ATUALIZADO ==='); + finalDeliveries.forEach((delivery, index) => { + console.log(`📦 Entrega ${index + 1} (deliverySeq: ${delivery.deliverySeq}): ${delivery.customerName}`); + }); + + return finalDeliveries; +}; +``` + +### **PASSO 6: SERVIÇO DE API** + +```typescript +// src/services/api.ts + +class ApiService { + private baseUrl: string; + private token: string | null = null; + + constructor() { + this.baseUrl = 'https://api.entrega.homologacao.jurunense.com'; + this.loadToken(); + } + + // Enviar ordem de roteamento + async sendRoutingOrder(routingData: RoutingData[]): Promise { + try { + console.log('=== DEBUG: INICIANDO sendRoutingOrder ==='); + console.log('Dados recebidos:', JSON.stringify(routingData, null, 2)); + + // Validar e normalizar coordenadas + const validatedRoutingData = routingData.map((item, index) => { + let lat: any = item.lat; + let lng: any = item.lng; + + // Converter strings com vírgula para ponto + if (typeof lat === 'string') { + lat = parseFloat(lat.replace(',', '.')); + if (isNaN(lat)) { + console.warn(`⚠️ Coordenada lat inválida: ${item.lat}, usando 0`); + lat = 0; + } + } + + if (typeof lng === 'string') { + lng = parseFloat(lng.replace(',', '.')); + if (isNaN(lng)) { + console.warn(`⚠️ Coordenada lng inválida: ${item.lng}, usando 0`); + lng = 0; + } + } + + // Garantir que sejam números + lat = Number(lat) || 0; + lng = Number(lng) || 0; + + return { ...item, lat, lng }; + }); + + const token = await this.loadToken(); + if (!token) throw new Error('Token não encontrado'); + + const ENDPOINT = `${this.baseUrl}/v1/driver/routing`; + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; + + const response = await fetch(ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(validatedRoutingData) + }); + + if (response.status === 401) { + throw new Error('Sessão expirada. Faça login novamente.'); + } + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.message || 'Erro ao enviar ordem de roteamento'); + } + + console.log('✅ Roteirização bem-sucedida'); + return result.data; + } catch (error) { + console.error('❌ Erro em sendRoutingOrder:', error); + throw error; + } + } + + // Carregar entregas + async getDeliveries(): Promise { + try { + const token = await this.loadToken(); + if (!token) throw new Error('Token não encontrado'); + + const response = await fetch(`${this.baseUrl}/v1/driver/deliveries`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.status === 401) { + throw new Error('Sessão expirada'); + } + + const result = await response.json(); + return Array.isArray(result) ? result : []; + } catch (error) { + console.error('❌ Erro ao carregar entregas:', error); + throw error; + } + } + + private async loadToken(): Promise { + try { + const storedToken = await AsyncStorage.getItem(AUTH_TOKEN_KEY); + if (storedToken) { + this.token = storedToken; + return storedToken; + } + return null; + } catch (error) { + console.error("Erro ao carregar token:", error); + return null; + } + } +} + +export const api = new ApiService(); +``` + +### **PASSO 7: TELA PRINCIPAL DE ROTEIRIZAÇÃO** + +```typescript +// src/screens/main/RoutingScreen.tsx + +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, + Alert, + RefreshControl +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { COLORS, SHADOWS } from '../../constants/theme'; +import { Delivery } from '../../types'; +import { api, optimizeRouteWithTSP } from '../../services/api'; + +const RoutingScreen: React.FC<{ navigation: any; route: any }> = ({ navigation, route }) => { + const [deliveries, setDeliveries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRouting, setIsRouting] = useState(false); + const [routingProgress, setRoutingProgress] = useState(0); + const [error, setError] = useState(null); + + // Carregar entregas iniciais + useEffect(() => { + loadDeliveries(); + }, []); + + // Carregar entregas do endpoint + const loadDeliveries = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await api.getDeliveries(); + + if (response) { + const deliveriesData = Array.isArray(response) ? response : []; + console.log('📦 Entregas processadas:', deliveriesData.length); + + // Ordenar entregas usando algoritmo TSP + try { + const sortedDeliveries = await optimizeRouteWithTSP(deliveriesData); + console.log('✅ Entregas ordenadas com sucesso:', sortedDeliveries.length); + setDeliveries(sortedDeliveries); + + // Verificar se já tem roteirização + const hasRouting = deliveriesData.some((delivery: Delivery) => delivery.routing === 1); + if (hasRouting) { + console.log('✅ Já tem roteirização - Redirecionando'); + navigation.reset({ + index: 0, + routes: [{ name: 'Main' as never }], + }); + } + } catch (sortError) { + console.error('❌ Erro ao ordenar entregas:', sortError); + setDeliveries(deliveriesData); + } + } + } catch (error: any) { + console.error('❌ Erro ao carregar entregas:', error); + setError(error.message || 'Erro ao carregar entregas'); + } finally { + setIsLoading(false); + } + }; + + // Executar roteirização + const executeRouting = async () => { + try { + setIsRouting(true); + setRoutingProgress(0); + setError(null); + + // Filtrar entregas sem roteirização + const deliveriesToRoute = deliveries.filter(delivery => delivery.routing === 0); + + if (deliveriesToRoute.length === 0) { + Alert.alert('Info', 'Todas as entregas já estão roteirizadas'); + return; + } + + setRoutingProgress(20); + + // SOLUÇÃO DEFINITIVA: OTIMIZAR ROTA ANTES DE ENVIAR PARA API + console.log('🎯 OTIMIZANDO ROTA COM ALGORITMO TSP ANTES DO ENVIO...'); + + const optimizedDeliveries = await optimizeRouteWithTSP(deliveriesToRoute); + console.log('✅ Rota otimizada com sucesso'); + + if (optimizedDeliveries && optimizedDeliveries.length > 0) { + // Preparar dados OTIMIZADOS para roteirização + const optimizedRoutingData = optimizedDeliveries.map((delivery) => ({ + outId: delivery.outId, + customerId: delivery.customerId, + deliverySeq: delivery.deliverySeq, // JÁ OTIMIZADO (1, 2, 3, ...) + lat: delivery.lat as number, + lng: delivery.lng as number + })); + + setRoutingProgress(60); + + // Executar roteirização com dados OTIMIZADOS + const routingResponse = await api.sendRoutingOrder(optimizedRoutingData); + + setRoutingProgress(80); + + if (routingResponse) { + console.log('✅ Roteirização com sequência otimizada executada com sucesso!'); + + // Atualizar estado local + setDeliveries(optimizedDeliveries); + + setRoutingProgress(100); + + Alert.alert( + 'Roteirização Concluída! 🎉', + `As entregas foram organizadas e ordenadas com sucesso!\n\nTotal: ${optimizedDeliveries.length} entregas\nPróxima: ${optimizedDeliveries[0]?.customerName || 'N/A'}`, + [ + { + text: 'OK', + onPress: () => { + navigation.reset({ + index: 0, + routes: [{ + name: 'Main' as never, + params: { + screen: 'Home', + params: { + routingUpdated: true, + refreshDeliveries: true + } + } + }], + }); + } + } + ] + ); + } + } + } catch (error: any) { + console.error('Erro na roteirização:', error); + setError(error.message || 'Erro ao executar roteirização'); + Alert.alert('Erro', 'Falha na roteirização. Tente novamente.'); + } finally { + setIsRouting(false); + setRoutingProgress(0); + } + }; + + // Refresh control + const onRefresh = async () => { + try { + await loadDeliveries(); + } catch (error: any) { + console.error('Erro no refresh:', error); + } + }; + + if (isLoading) { + return ( + + + Carregando entregas... + + ); + } + + return ( + + {/* Header */} + + navigation.reset({ + index: 0, + routes: [{ name: 'Main' as never }], + })} + > + + + + Roteirização de Entregas + + + + + + + {/* Conteúdo */} + + }> + {/* Resumo */} + + Resumo das Entregas + + + {deliveries.length} + Total + + + + {deliveries.filter(d => d.routing === 0).length} + + Pendentes + + + + {deliveries.filter(d => d.routing === 1).length} + + Roteirizadas + + + + {/* Informações de Ordenação */} + {deliveries.length > 0 && ( + + 📍 Ordenação por Sequência + + Próxima entrega: {deliveries[0]?.customerName || 'N/A'} + + + Sequência: #{deliveries[0]?.deliverySeq || 'N/A'} + + {deliveries[0]?.distance && ( + + Distância: {deliveries[0].distance.toFixed(2)} km + + )} + + )} + + + {/* Botão de Roteirização */} + {deliveries.some(d => d.routing === 0) && ( + + + {isRouting ? ( + + + + Roteirizando... {routingProgress}% + + + ) : ( + <> + + + Executar Roteirização + + + )} + + + )} + + {/* Lista de Entregas */} + + + Entregas ({deliveries.length}) + + + {deliveries.map((delivery, index) => ( + + + + #{delivery.outId} + + + + {getStatusText(delivery.status)} + + + + + + {delivery.customerName} + + + + {delivery.street}, {delivery.streetNumber} + + + + + + + {delivery.neighborhood}, {delivery.city} + + + + + + + {delivery.routing === 0 ? 'Aguardando roteirização' : 'Roteirizada'} + + + + + ))} + + + {/* Mensagem de erro */} + {error && ( + + + {error} + + )} + + + ); +}; + +// Funções auxiliares +const getStatusColor = (status: string) => { + switch (status) { + case 'delivered': return COLORS.success; + case 'in_progress': return COLORS.info; + case 'failed': return COLORS.danger; + default: return COLORS.warning; + } +}; + +const getStatusText = (status: string) => { + switch (status) { + case 'delivered': return 'Entregue'; + case 'in_progress': return 'Em andamento'; + case 'failed': return 'Falhou'; + default: return 'Pendente'; + } +}; + +// Estilos (implementar conforme necessário) +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: 50, + paddingBottom: 20, + paddingHorizontal: 20, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + color: 'white', + flex: 1, + textAlign: 'center', + }, + refreshButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + content: { + flex: 1, + padding: 20, + }, + summaryCard: { + backgroundColor: 'white', + borderRadius: 12, + padding: 20, + marginBottom: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + summaryTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 16, + }, + summaryStats: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + statItem: { + alignItems: 'center', + }, + statNumber: { + fontSize: 24, + fontWeight: 'bold', + color: '#007AFF', + }, + statLabel: { + fontSize: 12, + color: '#666', + marginTop: 4, + }, + routingButton: { + marginBottom: 20, + borderRadius: 12, + overflow: 'hidden', + }, + routingButtonDisabled: { + opacity: 0.7, + }, + routingButtonGradient: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + paddingHorizontal: 24, + }, + routingButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + routingProgress: { + flexDirection: 'row', + alignItems: 'center', + }, + routingProgressText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + marginLeft: 8, + }, + deliveriesSection: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginBottom: 16, + }, + deliveryItem: { + backgroundColor: 'white', + borderRadius: 12, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + deliveryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + deliveryNumber: { + fontSize: 14, + fontWeight: '600', + color: '#007AFF', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + statusText: { + fontSize: 10, + fontWeight: '500', + color: 'white', + }, + customerName: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 4, + }, + address: { + fontSize: 14, + color: '#666', + marginBottom: 8, + }, + deliveryMeta: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + metaItem: { + flexDirection: 'row', + alignItems: 'center', + }, + metaText: { + fontSize: 12, + color: '#666', + marginLeft: 4, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F5F5F5', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#333', + }, + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FEE2E2', + padding: 16, + borderRadius: 12, + marginTop: 20, + }, + errorText: { + marginLeft: 8, + fontSize: 14, + color: '#DC2626', + flex: 1, + }, + orderingInfo: { + marginTop: 16, + padding: 16, + backgroundColor: '#E0F2FE', + borderRadius: 8, + borderLeftWidth: 4, + borderLeftColor: '#007AFF', + }, + orderingTitle: { + fontSize: 14, + fontWeight: '600', + color: '#007AFF', + marginBottom: 8, + }, + orderingText: { + fontSize: 12, + color: '#666', + marginBottom: 4, + }, +}); + +export default RoutingScreen; +``` + +--- + +## **🚀 FLUXO COMPLETO DE ROTEIRIZAÇÃO** + +### **1. CARREGAMENTO INICIAL** +- ✅ Carregar entregas da API (`/v1/driver/deliveries`) +- ✅ Verificar se já tem roteirização (`routing === 1`) +- ✅ Se sim → redirecionar para Home +- ✅ Se não → mostrar tela de roteirização + +### **2. EXECUÇÃO DE ROTEIRIZAÇÃO** +- ✅ Filtrar entregas pendentes (`routing === 0`) +- ✅ **OTIMIZAR ROTA COM TSP** (antes de enviar para API) +- ✅ Preparar dados otimizados +- ✅ Enviar para API com `deliverySeq` já otimizado +- ✅ Atualizar estado local +- ✅ Redirecionar para Home + +### **3. ALGORITMO TSP** +- ✅ Obter centro de distribuição +- ✅ Preparar coordenadas (normalizar + geolocalizar) +- ✅ Calcular matriz de distâncias +- ✅ Executar Nearest Neighbor +- ✅ Aplicar sequência sequencial (1, 2, 3, ...) + +--- + +## ** PONTOS CRÍTICOS DE IMPLEMENTAÇÃO** + +### **1. ORDEM DE EXECUÇÃO CORRETA** +```typescript +// ❌ ERRADO: Enviar para API primeiro +await api.sendRoutingOrder(routingData); +const optimized = await optimizeRouteWithTSP(deliveries); + +// ✅ CORRETO: Otimizar primeiro, depois enviar +const optimized = await optimizeRouteWithTSP(deliveries); +await api.sendRoutingOrder(optimizedRoutingData); +``` + +### **2. NORMALIZAÇÃO DE COORDENADAS** +```typescript +// Sempre converter vírgula para ponto +const lat = parseFloat(coord.replace(',', '.')); +``` + +### **3. SEQUÊNCIA INICIAL** +```typescript +// Garantir que deliverySeq comece em 1, não em 0 +const newSeq = idx + 1; // ✅ Correto +const newSeq = idx; // ❌ Errado (começa em 0) +``` + +### **4. TRATAMENTO DE ERROS** +```typescript +try { + // Lógica principal +} catch (error: any) { + console.error('Erro detalhado:', error); + // Sempre reabilitar botões e mostrar feedback +} finally { + // Limpeza obrigatória + setIsRouting(false); + setRoutingProgress(0); +} +``` + +--- + +## **📱 DEPENDÊNCIAS NECESSÁRIAS** + +```json +{ + "dependencies": { + "@react-native-async-storage/async-storage": "^1.19.0", + "expo-linear-gradient": "^12.0.0", + "@expo/vector-icons": "^13.0.0", + "react-native-safe-area-context": "^4.7.0" + } +} +``` + +--- + +## **🔗 ENDPOINTS DA API** + +### **Base URL:** `https://api.entrega.homologacao.jurunense.com` + +| Endpoint | Método | Descrição | +|----------|--------|-----------| +| `/v1/driver/deliveries` | GET | Carregar lista de entregas | +| `/v1/driver/routing` | POST | Enviar ordem de roteirização | +| `/v1/geolocation/google` | GET | Geocodificar endereços | + +--- + +## **📊 ESTRUTURA DE DADOS** + +### **Entrada da API (sendRoutingOrder)** +```json +[ + { + "outId": 3673, + "customerId": 422973, + "deliverySeq": 1, + "lat": -1.3461972, + "lng": -48.3938122 + } +] +``` + +### **Resposta da API** +```json +{ + "success": true, + "message": "Roteirização de entrega atualizada com sucesso!", + "data": { + "message": "Roteirização de entrega atualizada com sucesso!" + }, + "timestamp": "2025-08-18T14:50:34.618Z", + "statusCode": 201 +} +``` + +--- + +## **🎯 RESULTADO ESPERADO** + +Após implementação, o sistema deve: + +1. **Carregar entregas** automaticamente da API +2. **Otimizar rotas** usando algoritmo TSP (Nearest Neighbor) +3. **Enviar sequência** otimizada para API (`/v1/driver/routing`) +4. **Persistir dados** no banco de dados +5. **Redirecionar** para tela principal +6. **Manter ordem** das entregas em todas as telas + +**Este sistema garante que as entregas sejam sempre ordenadas pela distância mais eficiente, começando do centro de distribuição e seguindo o algoritmo do caixeiro viajante! 🚚✨** + +--- + +## **📝 NOTAS IMPORTANTES** + +- **URL da API**: `https://api.entrega.homologacao.jurunense.com` +- **Algoritmo**: Nearest Neighbor TSP +- **Sequência**: Sempre começa em 1 (não em 0) +- **Coordenadas**: Sempre normalizar (vírgula → ponto) +- **Ordem**: Otimizar ANTES de enviar para API +- **Tratamento de Erro**: Sempre implementar try-catch-finally \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..0d62c64 --- /dev/null +++ b/app.json @@ -0,0 +1,92 @@ +{ + "expo": { + "name": "Entregas App", + "slug": "entregas-app", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.jurunense.entregasapp", + "infoPlist": { + "NSCameraUsageDescription": "Este aplicativo usa a câmera para capturar fotos das entregas.", + "NSPhotoLibraryUsageDescription": "Este aplicativo usa a galeria para selecionar fotos das entregas.", + "NSLocationWhenInUseUsageDescription": "Este aplicativo usa sua localização para mostrar rotas de entrega." + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.jurunense.entregasapp", + "permissions": [ + "CAMERA", + "READ_EXTERNAL_STORAGE", + "WRITE_EXTERNAL_STORAGE", + "ACCESS_COARSE_LOCATION", + "ACCESS_FINE_LOCATION" + ], + "config": { + "googleMaps": { + "apiKey": "AIzaSyBc0DiFwbS0yOJCuMi1KGwbc7_d1p8HyxQ" + } + } + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + [ + "expo-camera", + { + "cameraPermission": "Permitir que $(PRODUCT_NAME) acesse sua câmera." + } + ], + [ + "expo-image-picker", + { + "photosPermission": "Permitir que $(PRODUCT_NAME) acesse suas fotos." + } + ], + [ + "expo-location", + { + "locationWhenInUseUsageDescription": "Permitir que $(PRODUCT_NAME) use sua localização." + } + ], + "expo-sqlite", + "expo-font", + [ + "expo-notifications", + { + "icon": "./assets/notification-icon.png", + "color": "#ffffff" + } + ], + [ + "expo-maps", + { + "requestLocationPermission": true, + "locationPermission": "Permitir que $(PRODUCT_NAME) use sua localização para mostrar rotas de entrega." + } + ] + ], + "newArchEnabled": true, + "extra": { + "eas": { + "projectId": "your-project-id" + }, + "googleMapsApiKey": "AIzaSyBc0DiFwbS0yOJCuMi1KGwbc7_d1p8HyxQ" + } + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ac68442 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,94 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..17b2ce8 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'v0 App', + description: 'Created with v0', + generator: 'v0.dev', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..0d131c1 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import Navigation from "../src/navigation/index" + +export default function SyntheticV0PageForDeployment() { + return +} \ No newline at end of file diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png new file mode 100644 index 0000000..df36127 Binary files /dev/null and b/assets/adaptive-icon.png differ diff --git a/assets/adaptive-icon.svg b/assets/adaptive-icon.svg new file mode 100644 index 0000000..790d681 --- /dev/null +++ b/assets/adaptive-icon.svg @@ -0,0 +1 @@ +Truck Delivery \ No newline at end of file diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..79cef3e Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..97c5041 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1 @@ +TD \ No newline at end of file diff --git a/assets/fonts/Roboto-Bold.ttf b/assets/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..a355c27 Binary files /dev/null and b/assets/fonts/Roboto-Bold.ttf differ diff --git a/assets/fonts/Roboto-Medium.ttf b/assets/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..39c63d7 Binary files /dev/null and b/assets/fonts/Roboto-Medium.ttf differ diff --git a/assets/fonts/Roboto-Regular.ttf b/assets/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..8c082c8 Binary files /dev/null and b/assets/fonts/Roboto-Regular.ttf differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..61d1d80 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..790d681 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1 @@ +Truck Delivery \ No newline at end of file diff --git a/assets/notification-icon.png b/assets/notification-icon.png new file mode 100644 index 0000000..df36127 Binary files /dev/null and b/assets/notification-icon.png differ diff --git a/assets/splash.png b/assets/splash.png new file mode 100644 index 0000000..777acd7 Binary files /dev/null and b/assets/splash.png differ diff --git a/assets/splash.svg b/assets/splash.svg new file mode 100644 index 0000000..465b33f --- /dev/null +++ b/assets/splash.svg @@ -0,0 +1 @@ +Truck Delivery \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..09be040 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,16 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + ["module:react-native-dotenv", { + "moduleName": "@env", + "path": ".env", + "blacklist": null, + "whitelist": null, + "safe": false, + "allowUndefined": true + }] + ] + }; + }; \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..d9ef0ae --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/FloatingPanicButton.tsx b/components/FloatingPanicButton.tsx new file mode 100644 index 0000000..a2448e6 --- /dev/null +++ b/components/FloatingPanicButton.tsx @@ -0,0 +1,861 @@ +"use client" + +import type React from "react" +import { useState, useEffect, useRef } from "react" +import { + StyleSheet, + TouchableOpacity, + View, + Text, + Vibration, + ActivityIndicator, + Dimensions, + Animated, + Linking, +} from "react-native" +import { Ionicons } from "@expo/vector-icons" +import * as Location from "expo-location" +import AsyncStorage from "@react-native-async-storage/async-storage" +import { PanGestureHandler, State } from "react-native-gesture-handler" +import { LinearGradient } from "expo-linear-gradient" +import { Modalize } from "react-native-modalize" +import { COLORS, SHADOWS } from "../src/constants/theme" +import { StatusBar } from "expo-status-bar" + +const STORAGE_KEY = "panic_button_position" +const { width, height } = Dimensions.get("window") +const BUTTON_SIZE = 56 + +interface EnhancedPanicButtonProps { + onPanic?: (location?: { latitude: number; longitude: number } | null) => void +} + +const EnhancedPanicButton: React.FC = ({ onPanic }) => { + const [loading, setLoading] = useState(false) + const [ready, setReady] = useState(false) + const [position, setPosition] = useState({ x: width - BUTTON_SIZE - 24, y: height - BUTTON_SIZE - 120 }) + const [currentModal, setCurrentModal] = useState<'main' | 'permission' | 'error' | null>(null) + + // Refs para os modais + const mainModalRef = useRef(null) + const permissionModalRef = useRef(null) + const errorModalRef = useRef(null) + + // Animated values + const translateX = useRef(new Animated.Value(position.x)).current + const translateY = useRef(new Animated.Value(position.y)).current + const lastOffset = useRef({ x: position.x, y: position.y }) + const pulseAnim = useRef(new Animated.Value(1)).current + const shakeAnim = useRef(new Animated.Value(0)).current + const glowAnim = useRef(new Animated.Value(0)).current + + // Animação de pulso e brilho do botão + useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.12, + duration: 1200, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1200, + useNativeDriver: true, + }), + ]), + ) + + const glow = Animated.loop( + Animated.sequence([ + Animated.timing(glowAnim, { + toValue: 1, + duration: 2000, + useNativeDriver: true, + }), + Animated.timing(glowAnim, { + toValue: 0, + duration: 2000, + useNativeDriver: true, + }), + ]), + ) + + pulse.start() + glow.start() + + return () => { + pulse.stop() + glow.stop() + } + }, []) + + // Carregar posição salva + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then((data) => { + if (data) { + const pos = JSON.parse(data) + setPosition(pos) + translateX.setValue(pos.x) + translateY.setValue(pos.y) + lastOffset.current = pos + } + setReady(true) + }) + }, []) + + // Salvar posição ao mover + const savePosition = (x: number, y: number) => { + const newPos = { + x: Math.max(0, Math.min(x, width - BUTTON_SIZE)), + y: Math.max(0, Math.min(y, height - BUTTON_SIZE)), + } + setPosition(newPos) + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newPos)) + lastOffset.current = newPos + } + + const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)) + + const onHandlerStateChange = (event: any) => { + if (event.nativeEvent.oldState === State.ACTIVE) { + let newX = lastOffset.current.x + event.nativeEvent.translationX + let newY = lastOffset.current.y + event.nativeEvent.translationY + newX = clamp(newX, 0, width - BUTTON_SIZE) + newY = clamp(newY, 0, height - BUTTON_SIZE) + savePosition(newX, newY) + translateX.setValue(newX) + translateY.setValue(newY) + translateX.setOffset(0) + translateY.setOffset(0) + } + } + + const handlePress = () => { + Vibration.vibrate([50, 100, 50]) + setCurrentModal('main') + mainModalRef.current?.open() + } + + const shakeAnimation = () => { + Animated.sequence([ + Animated.timing(shakeAnim, { toValue: 10, duration: 80, useNativeDriver: true }), + Animated.timing(shakeAnim, { toValue: -10, duration: 80, useNativeDriver: true }), + Animated.timing(shakeAnim, { toValue: 10, duration: 80, useNativeDriver: true }), + Animated.timing(shakeAnim, { toValue: -10, duration: 80, useNativeDriver: true }), + Animated.timing(shakeAnim, { toValue: 0, duration: 80, useNativeDriver: true }), + ]).start() + } + + const confirmPanic = async () => { + setLoading(true) + + try { + const { status } = await Location.requestForegroundPermissionsAsync() + if (status !== "granted") { + setLoading(false) + mainModalRef.current?.close() + setTimeout(() => { + setCurrentModal('permission') + permissionModalRef.current?.open() + }, 300) + shakeAnimation() + return + } + + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, + timeout: 10000, + }) + + setLoading(false) + mainModalRef.current?.close() + Vibration.vibrate([100, 200, 100, 200, 100]) + + if (onPanic) { + onPanic({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }) + } + } catch (e) { + setLoading(false) + mainModalRef.current?.close() + setTimeout(() => { + setCurrentModal('error') + errorModalRef.current?.open() + }, 300) + shakeAnimation() + } + } + + const handlePermissionDenied = () => { + permissionModalRef.current?.close() + if (onPanic) onPanic(null) + } + + const handleLocationError = () => { + errorModalRef.current?.close() + if (onPanic) onPanic(null) + } + + const openSettings = () => { + Linking.openSettings() + permissionModalRef.current?.close() + } + + const retryLocation = () => { + errorModalRef.current?.close() + setTimeout(() => { + setCurrentModal('main') + mainModalRef.current?.open() + }, 300) + } + + if (!ready) return null + + const renderMainModal = () => ( + + {/* Header com gradiente */} + + + + + + + + Botão de Pânico + Situação de emergência detectada + + + + {/* Corpo do modal */} + + + + + + + Use apenas em emergências reais + + + + + O que acontecerá: + + + + + + + + Localização Enviada + Sua posição GPS será compartilhada + + + + + + + + + Equipe Notificada + Emergência será comunicada imediatamente + + + + + + + + + Resposta Rápida + Ação imediata será iniciada + + + + + + {loading ? ( + + + + Obtendo sua localização... + Aguarde alguns segundos + + + ) : ( + + mainModalRef.current?.close()} + > + Cancelar + + + + + + Acionar Pânico + + + + )} + + + ) + + const renderPermissionModal = () => ( + + + + + + + + + Permissão Necessária + Acesso à localização requerido + + + + + + + + Por que precisamos? + + Para enviar sua localização exata à equipe de emergência e garantir que o socorro chegue rapidamente ao local correto. + + + + + + + Continuar sem GPS + + + + + + Abrir Configurações + + + + + + ) + + const renderErrorModal = () => ( + + + + + + + + + Erro de Localização + Não foi possível obter GPS + + + + + + + + O que fazer? + + • Verifique se o GPS está ativado{'\n'} + • Certifique-se de estar em local aberto{'\n'} + • O pânico será acionado mesmo sem localização + + + + + + + Acionar sem GPS + + + + + + Tentar Novamente + + + + + + ) + + return ( + <> + + + { + const { translationX, translationY } = event.nativeEvent + let newX = lastOffset.current.x + translationX + let newY = lastOffset.current.y + translationY + newX = clamp(newX, 0, width - BUTTON_SIZE) + newY = clamp(newY, 0, height - BUTTON_SIZE) + translateX.setValue(newX) + translateY.setValue(newY) + }} + onHandlerStateChange={onHandlerStateChange} + > + + {/* Efeito de brilho */} + + + + + + + + + + + + + {/* Modais sem Portal */} + setCurrentModal(null)} + > + {renderMainModal()} + + + setCurrentModal(null)} + > + {renderPermissionModal()} + + + setCurrentModal(null)} + > + {renderErrorModal()} + + + ) +} + +const styles = StyleSheet.create({ + fab: { + position: "absolute", + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: BUTTON_SIZE / 2, + zIndex: 999, + }, + glowEffect: { + position: "absolute", + width: BUTTON_SIZE + 16, + height: BUTTON_SIZE + 16, + borderRadius: (BUTTON_SIZE + 16) / 2, + backgroundColor: "#EF4444", + top: -8, + left: -8, + zIndex: -1, + }, + fabButton: { + width: "100%", + height: "100%", + borderRadius: BUTTON_SIZE / 2, + overflow: "hidden", + ...SHADOWS.large, + }, + fabGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + borderRadius: BUTTON_SIZE / 2, + }, + fabIconContainer: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.2)", + alignItems: "center", + justifyContent: "center", + }, + + // Estilos do Modalize + modalStyle: { + backgroundColor: "transparent", + }, + overlayStyle: { + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + childrenStyle: { + backgroundColor: "transparent", + }, + modalHandle: { + backgroundColor: "rgba(255, 255, 255, 0.3)", + width: 40, + height: 4, + }, + + // Conteúdo dos modais + modalContent: { + backgroundColor: COLORS.card, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + marginBottom: 0, + }, + modalHeader: { + paddingTop: 32, + paddingBottom: 24, + paddingHorizontal: 24, + }, + headerContent: { + alignItems: "center", + }, + iconContainer: { + marginBottom: 16, + }, + iconRing: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: "rgba(255, 255, 255, 0.2)", + alignItems: "center", + justifyContent: "center", + borderWidth: 2, + borderColor: "rgba(255, 255, 255, 0.3)", + }, + modalTitle: { + fontSize: 24, + fontWeight: "bold", + color: "white", + marginBottom: 8, + textAlign: "center", + }, + modalSubtitle: { + fontSize: 16, + color: "rgba(255, 255, 255, 0.9)", + textAlign: "center", + lineHeight: 22, + }, + modalBody: { + padding: 24, + }, + + // Seção de aviso + warningSection: { + marginBottom: 24, + }, + warningCard: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: "#F3E8FF", + }, + warningIconContainer: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(217, 119, 6, 0.1)", + alignItems: "center", + justifyContent: "center", + marginRight: 12, + }, + warningText: { + fontSize: 14, + color: "#92400E", + fontWeight: "600", + flex: 1, + }, + + // Seção de recursos + featuresSection: { + marginBottom: 24, + }, + featuresTitle: { + fontSize: 16, + fontWeight: "bold", + color: COLORS.text, + marginBottom: 16, + }, + featuresList: { + gap: 12, + }, + featureItem: { + flexDirection: "row", + alignItems: "center", + }, + featureIcon: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + marginRight: 12, + }, + featureContent: { + flex: 1, + }, + featureTitle: { + fontSize: 14, + fontWeight: "600", + color: COLORS.text, + marginBottom: 2, + }, + featureDescription: { + fontSize: 12, + color: COLORS.textLight, + lineHeight: 16, + }, + + // Seção de loading + loadingSection: { + marginBottom: 8, + }, + loadingContainer: { + alignItems: "center", + paddingVertical: 32, + paddingHorizontal: 24, + borderRadius: 16, + borderWidth: 1, + borderColor: "#FEE2E2", + }, + loadingText: { + fontSize: 16, + color: COLORS.text, + marginTop: 16, + fontWeight: "600", + }, + loadingSubtext: { + fontSize: 14, + color: COLORS.textLight, + marginTop: 4, + }, + + // Seções específicas dos modais + permissionSection: { + marginBottom: 24, + }, + permissionCard: { + alignItems: "center", + padding: 24, + borderRadius: 16, + borderWidth: 1, + borderColor: "#FEF3C7", + }, + permissionTitle: { + fontSize: 16, + fontWeight: "bold", + color: "#92400E", + marginTop: 12, + marginBottom: 8, + }, + permissionText: { + fontSize: 14, + color: "#92400E", + textAlign: "center", + lineHeight: 20, + }, + + errorSection: { + marginBottom: 24, + }, + errorCard: { + alignItems: "center", + padding: 24, + borderRadius: 16, + borderWidth: 1, + borderColor: "#FEE2E2", + }, + errorTitle: { + fontSize: 16, + fontWeight: "bold", + color: "#DC2626", + marginTop: 12, + marginBottom: 8, + }, + errorText: { + fontSize: 14, + color: "#DC2626", + textAlign: "center", + lineHeight: 20, + }, + + // Ações + actionsSection: { + flexDirection: "row", + gap: 12, + marginBottom: 20, + }, + cancelButton: { + flex: 1, + backgroundColor: COLORS.background, + borderRadius: 16, + paddingVertical: 16, + alignItems: "center", + borderWidth: 1.5, + borderColor: COLORS.border, + }, + cancelButtonText: { + fontSize: 16, + color: COLORS.textLight, + fontWeight: "600", + }, + confirmButton: { + flex: 1, + borderRadius: 16, + overflow: "hidden", + ...SHADOWS.medium, + }, + settingsButton: { + flex: 1, + borderRadius: 16, + overflow: "hidden", + ...SHADOWS.medium, + }, + retryButton: { + flex: 1, + borderRadius: 16, + overflow: "hidden", + ...SHADOWS.medium, + }, + confirmButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + gap: 8, + }, + confirmButtonText: { + fontSize: 16, + color: "white", + fontWeight: "bold", + }, +}) + +export default EnhancedPanicButton \ No newline at end of file diff --git a/components/Icon.tsx b/components/Icon.tsx new file mode 100644 index 0000000..012e061 --- /dev/null +++ b/components/Icon.tsx @@ -0,0 +1,53 @@ +import type React from "react" +import { View, StyleSheet } from "react-native" +import { + MaterialIcons, + MaterialCommunityIcons, + Ionicons, + FontAwesome, + FontAwesome5, + Feather +} from "@expo/vector-icons" + +// Tipos de ícones suportados +type IconType = "material" | "material-community" | "ionicons" | "font-awesome" | "font-awesome5" | "feather" + +interface IconProps { + type: IconType + name: string + size?: number + color?: string + style?: any +} + +const Icon: React.FC = ({ type, name, size = 24, color = "#000", style }) => { + const renderIcon = () => { + switch (type) { + case "material": + return + case "material-community": + return + case "ionicons": + return + case "font-awesome": + return + case "font-awesome5": + return + case "feather": + return + default: + return + } + } + + return {renderIcon()} +} + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + justifyContent: "center", + }, +}) + +export default Icon \ No newline at end of file diff --git a/components/TruckIcon.tsx b/components/TruckIcon.tsx new file mode 100644 index 0000000..c6bb1c7 --- /dev/null +++ b/components/TruckIcon.tsx @@ -0,0 +1,37 @@ +import type React from "react" +import { View } from "react-native" +import Svg, { Path } from "react-native-svg" + +interface IconProps { + color: string + size: number +} + +const TruckIcon: React.FC = ({ color, size }) => { + return ( + + + + + + + + ) +} + +export default TruckIcon + + \ No newline at end of file diff --git a/components/offline-screen.tsx b/components/offline-screen.tsx new file mode 100644 index 0000000..924e7e6 --- /dev/null +++ b/components/offline-screen.tsx @@ -0,0 +1,85 @@ +"use client" + +import { View, Text, StyleSheet } from "react-native" +import { SafeAreaView } from "react-native-safe-area-context" +import { COLORS, SIZES, SHADOWS } from "../constants/theme" +import Icon from "../../components/Icon" + +const OfflineScreen = () => { + return ( + + + + + + Sem conexão com a internet + + Verifique sua conexão de rede e tente novamente. O aplicativo requer uma conexão ativa com a internet para + funcionar corretamente. + + + + + Você foi desconectado automaticamente. Por favor, faça login novamente quando estiver online. + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.background, + }, + content: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 24, + }, + iconContainer: { + width: 150, + height: 150, + borderRadius: 75, + backgroundColor: "rgba(255, 193, 7, 0.1)", + justifyContent: "center", + alignItems: "center", + marginBottom: 32, + }, + title: { + fontSize: SIZES.xLarge, + fontWeight: "bold", + color: COLORS.text, + marginBottom: 16, + textAlign: "center", + }, + message: { + fontSize: SIZES.medium, + color: COLORS.textLight, + textAlign: "center", + marginBottom: 32, + lineHeight: 24, + }, + infoCard: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(0, 74, 141, 0.1)", + borderRadius: 12, + padding: 16, + width: "100%", + ...SHADOWS.small, + }, + infoIcon: { + marginRight: 12, + }, + infoText: { + flex: 1, + fontSize: SIZES.font, + color: COLORS.primary, + lineHeight: 22, + }, +}) + +export default OfflineScreen diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..24c788c --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>