diff --git a/app.json b/app.json index b1d3c30..28be934 100644 --- a/app.json +++ b/app.json @@ -1,42 +1,45 @@ { "expo": { - "name": "cruiseLovers", + "name": "cruise lovers", "slug": "cruiseLovers", "version": "1.0.0", "orientation": "portrait", - "icon": "./assets/images/icon.png", + "icon": "./assets/icons/logo.png", "scheme": "cruiselovers", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "LSApplicationQueriesSchemes": ["comgooglemaps", "waze"] + } }, "android": { "adaptiveIcon": { "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" + "foregroundImage": "./assets/icons/logo.png", + "backgroundImage": "./assets/icons/logo.png", + "monochromeImage": "./assets/icons/logo.png" }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false }, "web": { "output": "static", - "favicon": "./assets/images/favicon.png" + "favicon": "./assets/icons/logo.png" }, "plugins": [ "expo-router", [ "expo-splash-screen", { - "image": "./assets/images/splash-icon.png", + "image": "./assets/icons/logo.png", "imageWidth": 200, "resizeMode": "contain", "backgroundColor": "#ffffff", "dark": { "backgroundColor": "#000000" - } + }, } ] ], diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/app/(auth)/login/index.tsx b/app/(auth)/login/index.tsx new file mode 100644 index 0000000..4eb3fb3 --- /dev/null +++ b/app/(auth)/login/index.tsx @@ -0,0 +1,110 @@ +import { Ionicons } from '@expo/vector-icons'; +import React, { useState } from 'react'; +import { + Image, + ImageBackground, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, + StatusBar, +} from 'react-native'; +import styles from '@/styles/screens/auth/login.styles'; +import { useAuth } from '@/assets/contexts/useAuth'; +import { router, type Href } from 'expo-router'; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [localError, setLocalError] = useState(null); + const { login, isLoading, error } = useAuth(); + + const handleLogin = async () => { + if (!email.trim() || !password.trim()) { + setLocalError('Preenche email e palavra-passe.'); + return; + } + + setLocalError(null); + + try { + await login(email.trim(), password); + router.replace('/home' as Href); + } catch { + // O erro já é guardado no contexto; evitamos uncaught promise no ecrã. + } + }; + + return ( + + + + + + + + Login + Acede às tuas reservas, documentos e informaçoes de viagem. + + + + + + Email* + + + + + Palavra-passe* + + + + setShowPassword((prev) => !prev)} hitSlop={8}> + + + + + router.push('/recover' as Href)}> + Esqueci-me da palavra-passe + + + {!!(localError || error) && {localError || error}} + + + Entrar + + + + + + ); +} \ No newline at end of file diff --git a/app/(auth)/recover/index.tsx b/app/(auth)/recover/index.tsx new file mode 100644 index 0000000..8ff08b8 --- /dev/null +++ b/app/(auth)/recover/index.tsx @@ -0,0 +1,106 @@ +import { Ionicons } from '@expo/vector-icons'; +import { router, type Href } from 'expo-router'; +import React, { useState } from 'react'; +import { + Image, + ImageBackground, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, + StatusBar, +} from 'react-native'; +import styles from '@/styles/screens/auth/recover.styles'; + + +export default function Recover() { + const [email, setEmail] = useState(''); + const [localError, setLocalError] = useState(null); + const [showSuccessModal, setShowSuccessModal] = useState(false); + + const handleRecover = () => { + if (!email.trim()) { + setLocalError('Indica o teu email para continuar.'); + return; + } + + setLocalError(null); + setShowSuccessModal(true); + }; + + return ( + + + + + + + + Repor palavra-passe + Indica o teu email para receberes o link de recuperacao. + + + + + + Email* + + + + {!!localError && {localError}} + + + Recuperar Palavra-passe + + + + router.replace('/login' as Href)}> + Voltar ao Login + + + + + + + + + + + Verifica o teu email + Enviamos-te as instrucoes para recuperares a palavra-passe. + { + setShowSuccessModal(false); + router.replace('/login' as Href); + }}> + Ok + + + + + + ); +} \ No newline at end of file diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 54e11d0..8cff7c8 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,35 +1,145 @@ import { Tabs } from 'expo-router'; -import React from 'react'; +import { Image, Platform, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors } from '@/assets/styles/colors'; -import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export default function TabLayout() { - const colorScheme = useColorScheme(); +export default function TabsLayout() { + const insets = useSafeAreaInsets(); return ( ( + + ), + tabBarStyle: [ + styles.tabBar, + { + height: (Platform.OS === 'ios' ? 54 : 64) + insets.bottom, + paddingBottom: 8 + insets.bottom, + }, + ], + tabBarItemStyle: styles.tabItem, + tabBarActiveTintColor: colors.vermelho, + tabBarInactiveTintColor: colors.azul, + tabBarLabelStyle: { + fontSize: 14, + fontWeight: '600', + }, + tabBarHideOnKeyboard: true, }}> , + title: 'Perfil', + tabBarIcon: ({ focused }) => ( + + + + ), }} /> , + title: 'Inicio', + tabBarIcon: ({ focused }) => ( + + + + ), + }} + /> + ( + + + + ), }} /> ); } + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + paddingHorizontal: 50, + paddingTop: 16, + backgroundColor: colors.branco, + borderTopWidth: 0, + borderTopLeftRadius: 60, + borderTopRightRadius: 60, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 10, + shadowOffset: { width: 0, height: -2 }, + elevation: 10, + }, + tabItem: { + paddingVertical: 1, + }, + iconContainer: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 10, + }, + iconActiveContainer: { + width: 40, + height: 40, + borderRadius: 24, + backgroundColor: colors.vermelho, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 10, + }, + icon: { + width: 26, + height: 26, + }, + iconActive: { + width: 26, + height: 26, + tintColor: colors.branco, + }, + logo: { + width: 180, + height: 60, + }, +}); \ No newline at end of file diff --git a/app/(tabs)/contactos/index.tsx b/app/(tabs)/contactos/index.tsx new file mode 100644 index 0000000..11d8c03 --- /dev/null +++ b/app/(tabs)/contactos/index.tsx @@ -0,0 +1,337 @@ +import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; +import { useAuth } from '@/assets/contexts/useAuth'; +import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage'; +import { colors } from '@/assets/styles/colors'; +import { ContactData, ContactResponse, SocialMedia } from '@/assets/types'; +import styles from '@/styles/screens/tabs/contactos.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import * as Clipboard from 'expo-clipboard'; +import { useEffect, useState } from 'react'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { + Alert, + Image, + Linking, + Pressable, + RefreshControl, + ScrollView, + Text, + View, +} from 'react-native'; +// ─── helpers ────────────────────────────────────────────────────────────────── + +const openPhone = (phone: string) => { + const clean = phone.replace(/[^\d+]/g, ''); + Linking.openURL(`tel:${clean}`).catch(() => + Alert.alert('Erro', 'Não foi possível abrir a aplicação de telefone.') + ); +}; + +const openEmail = (email: string) => { + Linking.openURL(`mailto:${email}`).catch(() => + Alert.alert('Erro', 'Não foi possível abrir a aplicação de e-mail.') + ); +}; + +const openWhatsApp = (value: string) => { + const clean = value.replace(/[^\d+]/g, ''); + const url = `https://wa.me/${clean}`; + Linking.openURL(url).catch(() => + Alert.alert('Erro', 'Não foi possível abrir o WhatsApp.') + ); +}; + +const openMaps = (address: string, lat?: number, lng?: number) => { + const url = + lat != null && lng != null + ? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(address)}` + : `maps:?q=${encodeURIComponent(address)}`; + + Linking.openURL(url).catch(() => + Linking.openURL( + lat != null && lng != null + ? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}` + : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` + ).catch(() => Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.')) + ); +}; + +const copyToClipboard = async (value: string, label: string) => { + await Clipboard.setStringAsync(value); + Alert.alert('Copiado', `${label} copiado para a área de transferência.`); +}; + +const extractPhone = (url: string): string => { + const match = url.match(/(?:phone=|wa\.me\/)([\d+]+)/); + if (match) return `+${match[1].replace(/^\+/, '')}`; + const clean = url.replace(/[^\d+]/g, ''); + return clean.startsWith('+') ? clean : `+${clean}`; +}; + +const parseCoords = (raw?: string | null): { lat: number; lng: number } | null => { + if (!raw) return null; + const parts = raw.split(',').map((s) => parseFloat(s.trim())); + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + return { lat: parts[0], lng: parts[1] }; + } + return null; +}; + + +const parseHorarios = (raw: string): { dia: string; horas: string }[] => { + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const sep = line.lastIndexOf(':'); + if (sep > 0) { + return { dia: line.slice(0, sep).trim(), horas: line.slice(sep + 1).trim() }; + } + const parts = line.split(/\s{2,}|\t/); + if (parts.length >= 2) { + return { dia: parts[0].trim(), horas: parts.slice(1).join(' ').trim() }; + } + return { dia: line, horas: '' }; + }); +}; + +const SOCIAL_ICONS: Record = { + facebook: 'facebook', + instagram: 'instagram', + tiktok: 'music', // FontAwesome doesn't have tiktok + twitter: 'twitter', + linkedin: 'linkedin', + youtube: 'youtube-play', +}; + +// ─── screen ─────────────────────────────────────────────────────────────────── + +export default function Contactos() { + const { token } = useAuth(); + const [contactData, setContactData] = useState(null); + const [socials, setSocials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [mapLoading, setMapLoading] = useState(true); + const [mapError, setMapError] = useState(false); + + const fetchContacts = async () => { + // 1. Mostrar cache imediatamente enquanto atualiza em fundo + const cached = await getCachedContacts(); + if (cached.contactData) { + setContactData(cached.contactData); + setSocials(cached.socials); + setIsLoading(false); + } + + try { + const formData = new FormData(); + if (token) formData.append('token', token); + + const response = await fetch(buildApiUrl(API_ENDPOINTS.CONTACTS), { + method: 'POST', + headers: { Accept: 'application/json' }, + body: formData, + }); + + const data: ContactResponse = await response.json(); + + console.log('data', data); + + if (data.status === 200 && data.contact) { + const validSocials = (data.socials || []).filter((s) => s.value?.trim()); + setContactData(data.contact); + setSocials(validSocials); + setError(null); + await cacheContacts(data.contact, validSocials); + } else if (!cached.contactData) { + setError(data.message || 'Não foi possível carregar os contactos.'); + } + } catch { + if (!cached.contactData) { + setError('Sem ligação à internet e sem dados guardados.'); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchContacts(); + }, [token]); + + const onRefresh = async () => { + setRefreshing(true); + await fetchContacts(); + setRefreshing(false); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error && !contactData) { + return ( + + {error} + + ); + } + + const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : []; + const coords = parseCoords(contactData?.coordenadas); + const mapUrl = coords + ? `https://staticmap.openstreetmap.de/staticmap.php?center=${coords.lat},${coords.lng}&zoom=15&size=600x300&markers=${coords.lat},${coords.lng},red-pushpin` + : null; + + return ( + + }> + + Contactos Agência + + {/* ── Ações rápidas ── */} + + {!!contactData?.mobilePhone && ( + openPhone(contactData.telephone)}> + + + + Ligar Agora + + {contactData.telephone} + + + )} + + {!!contactData?.email && ( + openEmail(contactData.email)}> + + + + Enviar Email + + {contactData.email} + + + )} + + {!!contactData?.mobilePhone && ( + openWhatsApp(contactData.mobilePhone!)}> + + + + Falar no Chat + + {extractPhone(contactData.mobilePhone)} + + + )} + + + {/* ── Localização ── */} + {!!contactData?.address && ( + + Localização + + {!!mapUrl && ( + openMaps(contactData.address, coords?.lat, coords?.lng)} + style={styles.mapImageWrap}> + { setMapLoading(true); setMapError(false); }} + onLoad={() => setMapLoading(false)} + onError={() => { setMapLoading(false); setMapError(true); }} + /> + {mapLoading && !mapError && ( + + + + )} + {mapError && ( + + + Mapa indisponível + + )} + + )} + + + + {contactData.address} + copyToClipboard(contactData.address, 'Morada')}> + + + + openMaps(contactData.address, coords?.lat, coords?.lng)}> + Obter Direções + + + + )} + + {/* ── Horário de Funcionamento ── */} + {horarios.length > 0 && ( + + Horário de Funcionamento + {horarios.map((item, idx) => ( + + {item.dia} + + {item.horas || '—'} + + + ))} + + )} + + {/* ── Redes Sociais ── */} + {socials.length > 0 && ( + + Redes Sociais + + {socials.map((social, idx) => ( + Linking.openURL(social.value).catch(() => null)}> + + + ))} + + + )} + + + ); +} diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 71518f9..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx new file mode 100644 index 0000000..9d7b800 --- /dev/null +++ b/app/(tabs)/home/index.tsx @@ -0,0 +1,333 @@ +import { colors } from '@/assets/styles/colors'; +import { useAuth } from '@/assets/contexts/useAuth'; +import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; +import { cacheReservas, getCachedReservas } from '@/assets/services/offlineStorage'; +import { downloadDocumentos, getDocumentosChecksums, verificarDocumentosParaDownload } from '@/assets/services/documentSync'; +import { Reserva, ReservasResponse } from '@/assets/types'; +import { Href, router } from 'expo-router'; +import styles from '@/styles/screens/tabs/home.styles'; +import { useEffect, useMemo, useState } from 'react'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { + Alert, + Image, + Pressable, + RefreshControl, + ScrollView, + Text, + View, +} from 'react-native'; +import { FontAwesome } from '@expo/vector-icons'; + +const getStatusPriority = (statusCode: string): number => { + switch (statusCode) { + case '10': + return 1; // Em viagem + case '1': + case '5': + return 2; // Confirmada + case '-1': + return 3; // Em pagamento + case '-2': + case '-3': + return 4; // Pendente + case '99': + return 5; // Viagem realizada + case '-5': + return 7; // Cancelada — sempre no fim + default: + return 6; + } +}; + +export default function Home() { + const { token } = useAuth(); + const [reservas, setReservas] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [isFromCache, setIsFromCache] = useState(false); + + const syncDocumentos = async () => { + if (!token) return; + try { + const checksumsData = await getDocumentosChecksums(token); + if (!checksumsData || checksumsData.status !== 200 || !checksumsData.documentos?.length) return; + + const documentosParaDownload = await verificarDocumentosParaDownload(checksumsData.documentos); + if (!documentosParaDownload.length) return; + + Alert.alert( + 'Documentos disponíveis', + `Existem ${documentosParaDownload.length} ficheiro${documentosParaDownload.length === 1 ? '' : 's'} novo${documentosParaDownload.length === 1 ? '' : 's'} para descarregar/atualizar. Pretende descarregá-los agora?`, + [ + { text: 'Não', style: 'cancel' }, + { + text: 'Sim', + onPress: () => downloadDocumentos(documentosParaDownload), + }, + ], + ); + } catch { + // sync de documentos é silencioso — não bloqueia o utilizador + } + }; + + const fetchReservas = async () => { + try { + setError(null); + + if (!token) { + setReservas([]); + return; + } + + const url = buildApiUrl(API_ENDPOINTS.USER_RESERVAS); + const formData = new FormData(); + formData.append('token', token); + + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + body: formData, + }); + + const data: ReservasResponse = await response.json(); + + if (!response.ok || (data.status !== 200 && data.status !== '200')) { + throw new Error(data.message || 'Erro ao carregar reservas'); + } + + const orderedReservas = [...(data.reservas || [])].sort((a, b) => { + const priorityDiff = + getStatusPriority(a.statusCode) - getStatusPriority(b.statusCode); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.startDate).getTime() - new Date(b.startDate).getTime(); + }); + + setReservas(orderedReservas); + setIsFromCache(false); + await cacheReservas(orderedReservas); + syncDocumentos(); + } catch { + const cached = await getCachedReservas(); + if (cached && cached.length > 0) { + setReservas(cached); + setIsFromCache(true); + setError(null); + } else { + setError('Sem ligação à internet e sem dados guardados.'); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchReservas(); + }, [token]); + + const onRefresh = async () => { + setRefreshing(true); + await fetchReservas(); + setRefreshing(false); + }; + + const stats = useMemo(() => { + const now = new Date(); + const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now); + const total = reservas.length; + const confirmadas = reservas.filter((reserva) => reserva.statusCode === '1').length; + const proxima = proximas[0]; + + let dias = '--'; + if (proxima) { + const diff = new Date(proxima.startDate).getTime() - now.getTime(); + dias = `${Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))}`; + } + + const reservaEmViagem = reservas.find((reserva) => reserva.statusCode === '10'); + + return { + dias, + total, + confirmadas, + emViagem: Boolean(reservaEmViagem), + destinoEmViagem: reservaEmViagem?.destino?.trim() || '—', + }; + }, [reservas]); + + const getStatusType = (statusCode: string) => { + if (statusCode === '1' || statusCode === '5') return 'verde'; + if (statusCode === '-5') return 'vermelho'; + if (statusCode === '10' || statusCode === '99') return 'roxo'; + if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo'; + return 'neutral'; + }; + + const getStatusColor = (statusType: string) => { + switch (statusType) { + case 'verde': + return '#13AE45'; + case 'vermelho': + return '#E6463B'; + case 'roxo': + return '#B138E6'; + case 'amarelo': + return '#C99700'; + default: + return '#4A6592'; + } + }; + + const getStatusConfig = (statusCode: string) => { + switch (statusCode) { + case '1': + return { label: 'Confirmada', icon: 'check-circle' as const }; + case '5': + return { label: 'Confirmada*', icon: 'check-circle' as const }; + case '-5': + return { label: 'Cancelada', icon: 'times-circle' as const }; + case '10': + return { label: 'Em Viagem', icon: 'ship' as const }; + case '99': + return { label: 'Viagem Realizada', icon: 'ship' as const }; + case '-2': + case '-3': + return { label: 'Pendente', icon: 'clock-o' as const }; + case '-1': + return { label: 'Em pagamento', icon: 'clock-o' as const }; + default: + return { label: 'Não definido', icon: 'question-circle' as const }; + } + }; + + if (isLoading) { + return ( + + + + ); + } + + console.log(reservas); + + return ( + + }> + + + + {stats.emViagem ? ( + + + + ) : ( + + )} + + {stats.emViagem ? 'Em viagem' : 'Proxima Viagem'} + + + {stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`} + + + + + + Total Reservas + {stats.total} + + + + + Confirmadas + {stats.confirmadas} + + + + {isFromCache && ( + + + Sem ligação — a mostrar dados guardados + + )} + + As minhas Reservas + + {!!error && {error}} + + {!error && reservas.length === 0 && ( + Sem reservas para apresentar. + )} + + {!error && + reservas.map((reserva, index) => { + const statusType = getStatusType(reserva.statusCode); + const statusConfig = getStatusConfig(reserva.statusCode); + const statusColor = getStatusColor(statusType); + return ( + + router.push(`/reserva/${encodeURIComponent(reserva.referenciaViagem || '')}` as Href) + }> + + + + {reserva.referenciaViagem || '---'} + + + + + {statusConfig.label} + + + + + + {reserva.destino} + {reserva.pais} + + + {reserva.startDate} + + + + + + + ); + })} + + + ); +} \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx deleted file mode 100644 index 786b736..0000000 --- a/app/(tabs)/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Link } from 'expo-router'; - -export default function HomeScreen() { - return ( - - }> - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} - /> - - alert('Delete pressed')} - /> - - - - - - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - - ); -} - -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, -}); diff --git a/app/(tabs)/perfil/index.tsx b/app/(tabs)/perfil/index.tsx new file mode 100644 index 0000000..937e6ee --- /dev/null +++ b/app/(tabs)/perfil/index.tsx @@ -0,0 +1,370 @@ +import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; +import { useAuth } from '@/assets/contexts/useAuth'; +import ChevronRightIcon from '@/assets/icons/chevron-right.svg'; +import UserDeleteIcon from '@/assets/icons/user-xmark-solid-full.svg'; +import LogoutIcon from '@/assets/icons/right-from-bracket-solid-full.svg'; +import UserIcon from '@/assets/icons/user-solid-full.svg'; +import { colors } from '@/assets/styles/colors'; +import { UserInfoResponse } from '@/assets/types'; +import styles from '@/styles/screens/tabs/perfil.styles'; +import { type Href, router } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { + Alert, + Image, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + RefreshControl, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const ANIM_DURATION = 280; + +export default function Perfil() { + const insets = useSafeAreaInsets(); + const { token, logout, updateProfile, isLoading } = useAuth(); + const [userInfo, setUserInfo] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [isEditingOpen, setIsEditingOpen] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeletingAccount, setIsDeletingAccount] = useState(false); + const [confirmModal, setConfirmModal] = useState(null); + + const progress = useSharedValue(0); + const contentHeight = useSharedValue(0); + const [measuredHeight, setMeasuredHeight] = useState(0); + + useEffect(() => { + if (measuredHeight > 0) { + contentHeight.value = measuredHeight; + } + }, [measuredHeight, contentHeight]); + + useEffect(() => { + progress.value = withTiming(isEditingOpen ? 1 : 0, { + duration: ANIM_DURATION, + easing: Easing.out(Easing.cubic), + }); + }, [isEditingOpen, progress]); + + const editBodyAnimatedStyle = useAnimatedStyle(() => ({ + height: contentHeight.value * progress.value, + opacity: progress.value, + })); + + const chevronAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 90}deg` }], + })); + + const getUserInfo = async () => { + if (!token) return; + try { + const formData = new FormData(); + formData.append('token', token); + const response = await fetch(buildApiUrl(API_ENDPOINTS.USER_INFO), { + method: 'POST', + body: formData, + }); + const data: UserInfoResponse = await response.json(); + console.log('data', data); + setUserInfo(data); + } catch { + Alert.alert('Erro', 'Nao foi possivel carregar os dados do perfil.'); + } + }; + + useEffect(() => { + getUserInfo(); + }, [token]); + + const onRefresh = async () => { + setRefreshing(true); + await getUserInfo(); + setRefreshing(false); + }; + + const handleSave = async () => { + if (newPassword || confirmPassword) { + if (!newPassword || !confirmPassword) { + Alert.alert('Erro', 'Preenche os dois campos de palavra-passe.'); + return; + } + if (newPassword !== confirmPassword) { + Alert.alert('Erro', 'As palavras-passe nao coincidem.'); + return; + } + } + + setIsSubmitting(true); + try { + await updateProfile( + userInfo?.user?.nome, + userInfo?.user?.apelido, + undefined, + newPassword || undefined, + ); + Alert.alert('Sucesso', 'Perfil atualizado com sucesso.'); + setNewPassword(''); + setConfirmPassword(''); + setIsEditingOpen(false); + await getUserInfo(); + } catch (err) { + Alert.alert('Erro', err instanceof Error ? err.message : 'Nao foi possivel atualizar o perfil.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleLogout = () => { + setConfirmModal('logout'); + }; + + const handleDeleteAccount = () => { + setConfirmModal('delete'); + }; + + const confirmDeleteAccount = () => { + Alert.alert( + 'Confirmacao final', + 'Todos os dados serao removidos permanentemente. Confirmar eliminacao?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + if (!token) return; + setIsDeletingAccount(true); + try { + const formData = new FormData(); + formData.append('token', token); + const response = await fetch(buildApiUrl(API_ENDPOINTS.DELETE_ACCOUNT), { + method: 'POST', + body: formData, + }); + const data = await response.json(); + if (response.ok && data?.status === 200) { + await logout(); + router.replace('/login' as Href); + Alert.alert('Conta eliminada', 'A sua conta foi eliminada com sucesso.'); + } else { + Alert.alert('Erro', data?.message || 'Nao foi possivel eliminar a conta.'); + } + } catch { + Alert.alert('Erro', 'Falha ao contactar o servidor.'); + } finally { + setIsDeletingAccount(false); + } + }, + }, + ] + ); + }; + + const confirmModalAction = async () => { + if (confirmModal === 'logout') { + try { + await logout(); + setConfirmModal(null); + router.replace('/login' as Href); + } catch { + Alert.alert('Erro', 'Nao foi possivel terminar sessao.'); + } + return; + } + + if (confirmModal === 'delete') { + setConfirmModal(null); + confirmDeleteAccount(); + } + }; + + const keyboardVerticalOffset = Platform.OS === 'ios' ? insets.top + 56 : 0; + + return ( + + }> + + O meu Perfil + + + {userInfo?.user?.nome + ' ' + userInfo?.user?.apelido || 'Utilizador'} + {userInfo?.user?.email || 'Utilizador'} + + + + setIsEditingOpen((prev) => !prev)}> + + + Editar Perfil + + + + + + + + { + const h = Math.ceil(e.nativeEvent.layout.height); + if (h > 0) setMeasuredHeight(h); + }}> + + + + Nome + + setUserInfo((prev) => + prev?.user ? { ...prev, user: { ...prev.user, nome: text } } : prev, + ) + } + style={styles.input} + placeholder="Nome" + /> + + + Apelido + + setUserInfo((prev) => + prev?.user ? { ...prev, user: { ...prev.user, apelido: text } } : prev, + ) + } + style={styles.input} + placeholder="Apelido" + /> + + + + + Nova Palavra-passe + + + + + Confirmar Nova Palavra-passe + + + + + {isSubmitting ? ( + + ) : ( + <> + Guardar Alteracoes + + + + )} + + + + + + + + + + + Log out + + + + + + + + + + {isDeletingAccount ? 'A eliminar...' : 'Eliminar Conta'} + + + + + + + {confirmModal && ( + setConfirmModal(null)}> + + + + {confirmModal === 'delete' ? ( + + ) : ( + + )} + + + {confirmModal === 'delete' ? 'Eliminar Conta' : 'Fazer Log out'} + + + {confirmModal === 'delete' + ? 'Tens a certeza que queres eliminar a tua conta?' + : 'Tens a certeza que queres sair da tua conta?'} + + + + + + {confirmModal === 'delete' ? (isDeletingAccount ? 'A eliminar...' : 'Eliminar') : 'Sair'} + + + + + setConfirmModal(null)}> + Cancelar + + + + + + + )} + + ); +} \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index f518c9b..6dd813c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,24 +1,41 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; +import { EmergencyButton } from '@/assets/components/EmergencyButton'; +import { AuthProvider } from '@/assets/contexts/useAuth'; +import { useFonts } from 'expo-font'; +import { Stack } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export const unstable_settings = { - anchor: '(tabs)', -}; +SplashScreen.preventAutoHideAsync(); export default function RootLayout() { - const colorScheme = useColorScheme(); + const [fontsLoaded] = useFonts({ + 'SpaceGrotesk-Regular': require('@/assets/fonts/SpaceGrotesk-Regular.ttf'), + 'SpaceGrotesk-Medium': require('@/assets/fonts/SpaceGrotesk-Medium.ttf'), + 'SpaceGrotesk-Bold': require('@/assets/fonts/SpaceGrotesk-Bold.ttf'), + 'SpaceGrotesk-SemiBold': require('@/assets/fonts/SpaceGrotesk-SemiBold.ttf'), + 'SpaceGrotesk-Light': require('@/assets/fonts/SpaceGrotesk-Light.ttf'), + }); + useEffect(() => { + if (fontsLoaded) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded]); + + if (!fontsLoaded) { + return null; + } return ( - - - - - - - + + + + + + + + ); } diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..dee23ce --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,29 @@ +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { useAuth } from '@/assets/contexts/useAuth'; +import { type Href, router } from 'expo-router'; +import { useEffect } from 'react'; +import { View } from 'react-native'; +import styles from '@/styles/screens/root/index.styles'; + +export default function Index() { + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (!isLoading) { + if (isAuthenticated) { + // Se estiver autenticado, redirecionar para home + router.replace('/home' as Href); + } else { + // Se não estiver autenticado, redirecionar para login + router.replace('/login' as Href); + } + } + }, [isAuthenticated, isLoading]); + + // Mostrar loading enquanto verifica autenticação + return ( + + + + ); +} diff --git a/app/modal.tsx b/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/app/modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -export default function ModalScreen() { - return ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/app/reserva/[referencia].tsx b/app/reserva/[referencia].tsx new file mode 100644 index 0000000..76946a4 --- /dev/null +++ b/app/reserva/[referencia].tsx @@ -0,0 +1,401 @@ +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { DashedDivider } from '@/assets/components/reserva/DashedDivider'; +import { DocumentoRow } from '@/assets/components/reserva/DocumentoRow'; +import { + formatCurrency, + formatDateLong, + formatDateShort, + getImageUrl, +} from '@/assets/components/reserva/formatters'; +import { HotelCard } from '@/assets/components/reserva/HotelCard'; +import { PassageiroCard } from '@/assets/components/reserva/PassageiroCard'; +import { Section } from '@/assets/components/reserva/Section'; +import { StatusBadge } from '@/assets/components/reserva/StatusBadge'; +import { ValorReservaCard } from '@/assets/components/reserva/ValorReservaCard'; +import { VooCard } from '@/assets/components/reserva/VooCard'; +import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; +import { useAuth } from '@/assets/contexts/useAuth'; +import { cacheReservaFull, getCachedReservaFull } from '@/assets/services/offlineStorage'; +import { + Documento, + Escala, + JsonData, + Pagamento, + ReservaData, + ReservaResponse, + VooDirection, + VooSegment, +} from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Image, + RefreshControl, + ScrollView, + Text, + View, +} from 'react-native'; + +export default function ReservaDetalheScreen() { + const { referencia } = useLocalSearchParams<{ referencia: string }>(); + const { token } = useAuth(); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [reservaData, setReservaData] = useState(null); + const [pagamentos, setPagamentos] = useState([]); + const [documentos, setDocumentos] = useState([]); + const [expandedPassageiros, setExpandedPassageiros] = useState>(new Set()); + const [cachedAt, setCachedAt] = useState(null); + const [isFromCache, setIsFromCache] = useState(false); + + const togglePassageiro = (idx: number) => { + setExpandedPassageiros((prev) => { + const next = new Set(prev); + if (next.has(idx)) { + next.delete(idx); + } else { + next.add(idx); + } + return next; + }); + }; + + const fetchReserva = async () => { + try { + setError(null); + if (!token || !referencia) { + throw new Error('Dados de autenticacao em falta'); + } + + const formData = new FormData(); + formData.append('token', token); + formData.append('referenciaViagem', referencia); + + const response = await fetch(buildApiUrl(API_ENDPOINTS.GET_RESERVA), { + method: 'POST', + headers: { Accept: 'application/json' }, + body: formData, + }); + + const data: ReservaResponse = await response.json(); + if (!response.ok || data.status !== 200 || !data.reserva) { + throw new Error(data.message || 'Nao foi possivel carregar a reserva'); + } + + const pagamentosData = data.pagamentos || []; + const documentosData = data.documentos || []; + const now = new Date().toISOString(); + + setReservaData(data.reserva); + setPagamentos(pagamentosData); + setDocumentos(documentosData); + setCachedAt(now); + setIsFromCache(false); + + await cacheReservaFull(referencia, { + reservaData: data.reserva, + pagamentos: pagamentosData, + documentos: documentosData, + cachedAt: now, + }); + } catch { + const cached = await getCachedReservaFull(referencia); + if (cached) { + setReservaData(cached.reservaData); + setPagamentos(cached.pagamentos); + setDocumentos(cached.documentos); + setCachedAt(cached.cachedAt); + setIsFromCache(true); + setError(null); + } else { + setError('Sem ligação à internet e sem dados guardados para esta reserva.'); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchReserva(); + }, [referencia, token]); + + const onRefresh = async () => { + setRefreshing(true); + await fetchReserva(); + setRefreshing(false); + }; + + const parsedJsonData = useMemo((): JsonData | null => { + if (!reservaData?.jsonData) return null; + try { + return typeof reservaData.jsonData === 'string' + ? (JSON.parse(reservaData.jsonData) as JsonData) + : reservaData.jsonData; + } catch { + return null; + } + }, [reservaData?.jsonData]); + + const hotelInfo = parsedJsonData?.hotel || []; + const extrasData = parsedJsonData?.extras ?? parsedJsonData?.extra ?? []; + const voosData = parsedJsonData?.voo; + + const getVoosFromLeg = (leg?: VooDirection): (Escala | VooSegment)[] => { + if (!leg) return []; + if (Array.isArray(leg)) return leg; + return leg.infoEscalas ?? []; + }; + + const voosIda = getVoosFromLeg(voosData?.departure); + const voosVolta = getVoosFromLeg(voosData?.arrival); + + const buildQuartosPassageiros = (data: ReservaData) => { + const partes: string[] = []; + if (data.quartos) partes.push(`${data.quartos} Quartos`); + if (data.adultos) partes.push(`${data.adultos} Adultos`); + if (data.criancas && data.criancas !== '0') partes.push(`${data.criancas} Crianças`); + return partes.length ? partes.join(' · ') : `${data.pessoas} Passageiros`; + }; + + const statusCode = String(reservaData?.status ?? ''); + + return ( + + ( + + ), + }} + /> + + {isLoading ? ( + + + + ) : ( + }> + {!!error && {error}} + + {reservaData && ( + <> + {isFromCache && ( + + + Sem ligação — a mostrar dados guardados + + )} + + {reservaData.destino} + +
+ {!!reservaData.imagemCidade && ( + + )} + + + ID #{reservaData.referenciaViagem} + Ref. da Reserva + + + + + + {reservaData.localizador || '---'} + Ref. do Operador + +
+ +
+ + + Destino + {reservaData.destino} + + + + Data Viagem + + {formatDateLong(reservaData.startDate)} -{' '} + {formatDateLong(reservaData.endDate)} + + + + + Nr. de Noites + {reservaData.noites} noites + + + + Quartos e Passageiros + {buildQuartosPassageiros(reservaData)} + + + + Operador + {reservaData.operador || '---'} + + +
+ +
+ {hotelInfo.length > 0 ? ( + hotelInfo.map((hotel, idx) => ( + + )) + ) : ( + Sem dados de alojamento. + )} +
+ +
+ {voosIda.length > 0 || voosVolta.length > 0 ? ( + + {voosIda.length > 0 && ( + + )} + {voosVolta.length > 0 && ( + + )} + + ) : ( + Sem dados de voos. + )} +
+ +
+ + {reservaData.passageiros && reservaData.passageiros.length > 0 ? ( + reservaData.passageiros.map((passageiro, idx) => ( + togglePassageiro(idx)} + /> + )) + ) : ( + Sem passageiros associados. + )} +
+ +
+ {extrasData.length > 0 ? ( + + {extrasData.map((extra) => ( + + {extra.name} + + ))} + + ) : ( + Sem extras associados. + )} +
+ +
+ {pagamentos.length > 0 ? ( + <> + + + Data + + + Valor + + + Meio Pag. + + + + {pagamentos.map((pagamento, idx) => ( + + + + + {formatDateShort(pagamento.data_pagamento || pagamento.data_hora)} + + + + + {formatCurrency(pagamento.valor)} + + + + {pagamento.metodoPagamento} + + + + + ))} + + ) : ( + Sem pagamentos registados. + )} +
+ + + +
+ + {documentos.length > 0 ? ( + documentos.map((documento, idx) => ( + + + + + )) + ) : ( + Sem documentos associados. + )} +
+ + {!!cachedAt && ( + + Atualizado em: {new Date(cachedAt).toLocaleDateString('pt-PT', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + )} + + )} +
+ )} +
+ ); +} diff --git a/assets/components/EmergencyButton.tsx b/assets/components/EmergencyButton.tsx new file mode 100644 index 0000000..ca26a07 --- /dev/null +++ b/assets/components/EmergencyButton.tsx @@ -0,0 +1,151 @@ +import { useAuth } from '@/assets/contexts/useAuth'; +import { getCachedContacts } from '@/assets/services/offlineStorage'; +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; +import { FontAwesome } from '@expo/vector-icons'; +import { useEffect, useState } from 'react'; +import { Alert, Linking, Platform, StyleSheet, Text, useWindowDimensions } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const BTN_SIZE = 64; +const MARGIN = 16; + +export function EmergencyButton() { + const { isAuthenticated } = useAuth(); + const insets = useSafeAreaInsets(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const [emergencyPhone, setEmergencyPhone] = useState(null); + + const tabBarHeight = (Platform.OS === 'ios' ? 54 : 64) + insets.bottom; + const maxX = screenWidth - BTN_SIZE - MARGIN; + const minY = insets.top + MARGIN; + const maxY = screenHeight - tabBarHeight - BTN_SIZE - MARGIN; + + const x = useSharedValue(screenWidth - BTN_SIZE - MARGIN); + const y = useSharedValue(screenHeight - tabBarHeight - BTN_SIZE - MARGIN); + const startX = useSharedValue(0); + const startY = useSharedValue(0); + const scale = useSharedValue(1); + const isDragActive = useSharedValue(false); + + useEffect(() => { + if (!isAuthenticated) return; + getCachedContacts().then(({ contactData }) => { + if (contactData?.emergencyPhone) { + setEmergencyPhone(contactData.emergencyPhone); + } + }); + }, [isAuthenticated]); + + const handlePress = () => { + if (!emergencyPhone) return; + const url = `tel:${emergencyPhone.replace(/\s/g, '')}`; + Alert.alert( + 'Linha de Emergência 24h', + `Ligar para ${emergencyPhone}?`, + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Ligar', + style: 'default', + onPress: () => + Linking.openURL(url).catch(() => + Alert.alert('Erro', 'Não foi possível abrir a aplicação de chamadas.'), + ), + }, + ], + ); + }; + + // Toque simples → abre o Alert + const tap = Gesture.Tap() + .maxDuration(500) + .onEnd(() => { + runOnJS(handlePress)(); + }); + + // Segurar 800ms → ativa modo arrasto com feedback de escala + const longPress = Gesture.LongPress() + .minDuration(800) + .onStart(() => { + isDragActive.value = true; + scale.value = withSpring(1.2, { damping: 12, stiffness: 200 }); + startX.value = x.value; + startY.value = y.value; + }); + + // Pan → só move quando o modo arrasto está ativo + const pan = Gesture.Pan() + .onUpdate((e) => { + if (!isDragActive.value) return; + x.value = Math.max(MARGIN, Math.min(maxX, startX.value + e.translationX)); + y.value = Math.max(minY, Math.min(maxY, startY.value + e.translationY)); + }) + .onEnd(() => { + if (!isDragActive.value) return; + isDragActive.value = false; + scale.value = withSpring(1, { damping: 15, stiffness: 250 }); + // snap para a borda mais próxima sem bounce + const snapRight = x.value + BTN_SIZE / 2 > screenWidth / 2; + x.value = withTiming(snapRight ? maxX : MARGIN, { duration: 250 }); + }); + + const composed = Gesture.Race( + tap, + Gesture.Simultaneous(longPress, pan), + ); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: x.value }, + { translateY: y.value }, + { scale: scale.value }, + ], + })); + + if (!isAuthenticated || !emergencyPhone) return null; + + return ( + + + + SOS + + + ); +} + +const styles = StyleSheet.create({ + btn: { + position: 'absolute', + top: 0, + left: 0, + width: BTN_SIZE, + height: BTN_SIZE, + borderRadius: BTN_SIZE / 2, + backgroundColor: colors.vermelho, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + shadowColor: '#000', + shadowOpacity: 0.3, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 10, + zIndex: 999, + }, + label: { + color: colors.branco, + fontFamily: fonts.bold, + fontSize: 11, + letterSpacing: 0.5, + }, +}); diff --git a/assets/components/LoadingSpinner.tsx b/assets/components/LoadingSpinner.tsx new file mode 100644 index 0000000..3e73dce --- /dev/null +++ b/assets/components/LoadingSpinner.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { Image, ImageStyle, StyleProp } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +const SIZES = { + small: 24, + large: 48, +} as const; + +type Props = { + size?: keyof typeof SIZES | number; + style?: StyleProp; +}; + +export function LoadingSpinner({ size = 'large', style }: Props) { + const dimension = typeof size === 'number' ? size : SIZES[size]; + const rotation = useSharedValue(0); + + useEffect(() => { + rotation.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + -1, + false, + ); + }, [rotation]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} diff --git a/assets/components/reserva/DashedDivider.tsx b/assets/components/reserva/DashedDivider.tsx new file mode 100644 index 0000000..790ec30 --- /dev/null +++ b/assets/components/reserva/DashedDivider.tsx @@ -0,0 +1,21 @@ +import { View } from 'react-native'; + +type Props = { + color?: string; + width?: number; + borderStyle?: 'dashed' | 'dotted' | 'solid'; +}; + +export function DashedDivider({ color = '#a9bcd9', width = 1, borderStyle = 'dashed' }: Props) { + return ( + + + + ); +} diff --git a/assets/components/reserva/DocumentoRow.tsx b/assets/components/reserva/DocumentoRow.tsx new file mode 100644 index 0000000..a892648 --- /dev/null +++ b/assets/components/reserva/DocumentoRow.tsx @@ -0,0 +1,98 @@ +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { colors } from '@/assets/styles/colors'; +import { downloadDocumento, getLocalDocumentUri } from '@/assets/services/documentSync'; +import { Documento } from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg'; +import * as Sharing from 'expo-sharing'; +import { useEffect, useState } from 'react'; +import { Alert, Pressable, Text, View } from 'react-native'; + +type Props = { + documento: Documento; + isLast: boolean; +}; + +const getDocumentExt = (path: string) => { + const ext = path.split('.').pop()?.toUpperCase() ?? 'DOC'; + return ext.length > 4 ? 'DOC' : ext; +}; + +export function DocumentoRow({ documento, isLast }: Props) { + const [isDownloading, setIsDownloading] = useState(false); + const [isDownloaded, setIsDownloaded] = useState(false); + + useEffect(() => { + if (!documento.idDocumento || !documento.caminhoFicheiro) return; + getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro).then((uri) => { + setIsDownloaded(!!uri); + }); + }, [documento.idDocumento, documento.caminhoFicheiro]); + + const fileName = + documento.nome || documento.caminhoFicheiro.split('/').pop() || '---'; + + const openLocalFile = async (uri: string) => { + const canShare = await Sharing.isAvailableAsync(); + if (canShare) { + await Sharing.shareAsync(uri, { UTI: '.pdf', mimeType: 'application/pdf' }); + } else { + Alert.alert('Erro', 'Não é possível abrir este ficheiro neste dispositivo.'); + } + }; + + const handlePress = async () => { + if (!documento.idDocumento || !documento.caminhoFicheiro) return; + + setIsDownloading(true); + try { + const localUri = await getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro); + + if (localUri) { + await openLocalFile(localUri); + return; + } + + // Ficheiro não está local — fazer download agora + const uri = await downloadDocumento({ + idDocumento: documento.idDocumento, + caminhoFicheiro: documento.caminhoFicheiro, + checksum: documento.checksum ?? '', + nome: documento.nome, + }); + + if (uri) { + setIsDownloaded(true); + await openLocalFile(uri); + } else { + Alert.alert('Erro', 'Não foi possível descarregar o documento. Verifica a tua ligação à internet.'); + } + } finally { + setIsDownloading(false); + } + }; + + return ( + + + + {getDocumentExt(documento.caminhoFicheiro)} + + + + {fileName} + + {isDownloading ? ( + + ) : isDownloaded ? ( + + ) : ( + + )} + + ); +} diff --git a/assets/components/reserva/FieldBox.tsx b/assets/components/reserva/FieldBox.tsx new file mode 100644 index 0000000..191d28b --- /dev/null +++ b/assets/components/reserva/FieldBox.tsx @@ -0,0 +1,18 @@ +import styles from '@/styles/screens/reserva/detail.styles'; +import { Text, View } from 'react-native'; + +type Props = { + label: string; + value?: string | null; +}; + +export function FieldBox({ label, value }: Props) { + return ( + + {label} + + {value || '---'} + + + ); +} diff --git a/assets/components/reserva/HotelCard.tsx b/assets/components/reserva/HotelCard.tsx new file mode 100644 index 0000000..675ca4c --- /dev/null +++ b/assets/components/reserva/HotelCard.tsx @@ -0,0 +1,47 @@ +import { HotelInfo } from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { Image, Text, View } from 'react-native'; +import { formatDateLong } from './formatters'; +import { Stars } from './Stars'; + +type Props = { + hotel: HotelInfo; + isFirst: boolean; +}; + +export function HotelCard({ hotel, isFirst }: Props) { + const img = hotel.imagemexterna || hotel.imageminterna; + + return ( + + + + + + {hotel.name} + + {!!hotel.regime && ( + + Regime: + {hotel.regime} + + )} + {!!hotel.data && ( + + Check-In: + {formatDateLong(hotel.data)} + + )} + + Nr. de Noites: + + {hotel.noites} {hotel.noites === 1 ? 'noite' : 'noites'} + + + + + ); +} diff --git a/assets/components/reserva/PassageiroCard.tsx b/assets/components/reserva/PassageiroCard.tsx new file mode 100644 index 0000000..89cde24 --- /dev/null +++ b/assets/components/reserva/PassageiroCard.tsx @@ -0,0 +1,126 @@ +import { colors } from '@/assets/styles/colors'; +import { Passageiro } from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import { useEffect, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { DashedDivider } from './DashedDivider'; +import { FieldBox } from './FieldBox'; + +type Props = { + passageiro: Passageiro; + index: number; + isOpen: boolean; + isFirst: boolean; + onToggle: () => void; +}; + +const ANIM_DURATION = 280; + +export function PassageiroCard({ passageiro, index, isOpen, onToggle }: Props) { + const progress = useSharedValue(isOpen ? 1 : 0); + const contentHeight = useSharedValue(0); + const [measuredHeight, setMeasuredHeight] = useState(0); + + useEffect(() => { + if (measuredHeight > 0) { + contentHeight.value = measuredHeight; + } + }, [measuredHeight, contentHeight]); + + useEffect(() => { + progress.value = withTiming(isOpen ? 1 : 0, { + duration: ANIM_DURATION, + easing: Easing.out(Easing.cubic), + }); + }, [isOpen, progress]); + + const bodyAnimatedStyle = useAnimatedStyle(() => ({ + height: contentHeight.value * progress.value, + opacity: progress.value, + })); + + const chevronAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + })); + + return ( + + + + + Passageiro {index + 1} + + {passageiro.nome} {passageiro.sobrenome} + + + + + + + + + { + const h = Math.ceil(e.nativeEvent.layout.height); + if (h > 0) setMeasuredHeight(h); + }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/assets/components/reserva/Section.tsx b/assets/components/reserva/Section.tsx new file mode 100644 index 0000000..897f6bb --- /dev/null +++ b/assets/components/reserva/Section.tsx @@ -0,0 +1,22 @@ +import styles from '@/styles/screens/reserva/detail.styles'; +import { ReactNode } from 'react'; +import { Text, View } from 'react-native'; +import { DashedDivider } from './DashedDivider'; + +type Props = { + title?: string; + children: ReactNode; +}; + +export function Section({ title, children }: Props) { + return ( + + {!!title && ( + <> + {title} + + )} + {children} + + ); +} diff --git a/assets/components/reserva/Stars.tsx b/assets/components/reserva/Stars.tsx new file mode 100644 index 0000000..89168b9 --- /dev/null +++ b/assets/components/reserva/Stars.tsx @@ -0,0 +1,20 @@ +import { colors } from '@/assets/styles/colors'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import { View } from 'react-native'; + +type Props = { + stars: string | number; +}; + +export function Stars({ stars }: Props) { + const count = Math.max(0, Math.min(5, parseInt(String(stars || '0'), 10))); + if (!count) return null; + return ( + + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} diff --git a/assets/components/reserva/StatusBadge.tsx b/assets/components/reserva/StatusBadge.tsx new file mode 100644 index 0000000..69f0ed1 --- /dev/null +++ b/assets/components/reserva/StatusBadge.tsx @@ -0,0 +1,88 @@ +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import { ComponentProps } from 'react'; +import { Text, View } from 'react-native'; + +type StatusType = 'verde' | 'vermelho' | 'roxo' | 'amarelo' | 'neutral'; +type IconName = ComponentProps['name']; + +type Props = { + statusCode: string; +}; + +const getStatusType = (statusCode: string): StatusType => { + if (statusCode === '1' || statusCode === '5') return 'verde'; + if (statusCode === '-5') return 'vermelho'; + if (statusCode === '10' || statusCode === '99') return 'roxo'; + if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo'; + return 'neutral'; +}; + +const getStatusColor = (type: StatusType) => { + switch (type) { + case 'verde': + return '#13AE45'; + case 'vermelho': + return '#E6463B'; + case 'roxo': + return '#B138E6'; + case 'amarelo': + return '#C99700'; + default: + return '#4A6592'; + } +}; + +const getStatusConfig = (statusCode: string): { label: string; icon: IconName } => { + switch (statusCode) { + case '1': + return { label: 'Confirmada', icon: 'check-circle' }; + case '5': + return { label: 'Confirmada - Aguarda pagamento total', icon: 'check-circle' }; + case '-5': + return { label: 'Cancelada', icon: 'times-circle' }; + case '10': + return { label: 'Em Viagem', icon: 'ship' }; + case '99': + return { label: 'Viagem Realizada', icon: 'ship' }; + case '-2': + return { label: 'Pendente de confirmação com Agente', icon: 'clock-o' }; + case '-3': + return { label: 'Pendente de Confirmação', icon: 'clock-o' }; + case '-1': + return { label: 'Aguarda pagamento inicial', icon: 'clock-o' }; + default: + return { label: 'Não definido', icon: 'question-circle' }; + } +}; + +export function StatusBadge({ statusCode }: Props) { + const type = getStatusType(statusCode); + const config = getStatusConfig(statusCode); + const color = getStatusColor(type); + + return ( + + + + + {config.label} + + + + ); +} diff --git a/assets/components/reserva/ValorReservaCard.tsx b/assets/components/reserva/ValorReservaCard.tsx new file mode 100644 index 0000000..14dd260 --- /dev/null +++ b/assets/components/reserva/ValorReservaCard.tsx @@ -0,0 +1,72 @@ +import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg'; +import { colors } from '@/assets/styles/colors'; +import { ReservaData } from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { Text, View } from 'react-native'; + +type Props = { + reserva: ReservaData; +}; + +const formatEuro = (value: string) => { + const n = parseFloat(value || '0'); + return `€${n.toLocaleString('pt-PT', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}`; +}; + +export function ValorReservaCard({ reserva }: Props) { + const total = parseFloat(reserva.precoTotalFinal || '0'); + const pago = parseFloat(reserva.valorPago || '0'); + const emFalta = parseFloat(reserva.valorAPagar || '0'); + const isFullyPaid = emFalta <= 0; + const percent = isFullyPaid + ? 100 + : total > 0 + ? Math.min(100, Math.max(0, (pago / total) * 100)) + : 0; + + return ( + + Valor da Reserva + + + {formatEuro(reserva.precoTotalFinal)} + Valor Total + + Pago {formatEuro(reserva.valorPago)} + + + {percent > 0 && ( + + + + )} + + + + {isFullyPaid ? ( + + + Pago + + ) : ( + <> + Em Falta + + + {formatEuro(reserva.valorAPagar)} + + + + )} + + + + ); +} diff --git a/assets/components/reserva/VooCard.tsx b/assets/components/reserva/VooCard.tsx new file mode 100644 index 0000000..267a4c6 --- /dev/null +++ b/assets/components/reserva/VooCard.tsx @@ -0,0 +1,158 @@ +import PlaneArrivalIcon from '@/assets/icons/plane-arrival-solid-full.svg'; +import PlaneDepartureIcon from '@/assets/icons/plane-departure-solid-full.svg'; +import { colors } from '@/assets/styles/colors'; +import { Escala, VooSegment } from '@/assets/types'; +import styles from '@/styles/screens/reserva/detail.styles'; +import { FontAwesome } from '@expo/vector-icons'; +import { Alert, Image, Linking, Platform, Pressable, Text, View } from 'react-native'; +import { formatDateLong } from './formatters'; + +type SegmentProps = { + voo: Escala | VooSegment; + tipo: 'ida' | 'volta'; + isLast: boolean; + onMapaPress?: () => void; +}; + +const planeIconProps = { width: 16, height: 16, fill: colors.vermelho } as const; + +function abrirMapaAeroporto(lat: number, lng: number, nome: string) { + const url = Platform.OS === 'ios' + ? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(nome)}` + : `geo:${lat},${lng}?q=${encodeURIComponent(nome)}`; + + Linking.openURL(url).catch(() => + Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'), + ); +} + +function VooSegmentCard({ voo, tipo }: Omit) { + const legLabel = tipo === 'ida' ? 'DATA DE IDA' : 'DATA DE REGRESSO'; + const LegPlaneIcon = tipo === 'ida' ? PlaneDepartureIcon : PlaneArrivalIcon; + + const escala = voo as Escala; + const gps = escala.infoAeroportoDeparture?.gps; + const nomeAeroporto = escala.infoAeroportoDeparture?.name ?? voo.departureAirport ?? voo.departureAirportCode; + + const handleMapaPress = () => { + if (gps?.lat != null && gps?.lng != null) { + abrirMapaAeroporto(Number(gps.lat), Number(gps.lng), nomeAeroporto); + } else { + Alert.alert('Sem coordenadas', 'Não há informação de localização para este aeroporto.'); + } + }; + + return ( + + + + {voo.departureAirportCode} + {voo.departureTime} + + {voo.departureAirportCode}-{voo.departureAirport} + + + + + {voo.flightTime} + + + + + + + + + + {voo.arrivalAirportCode} + {voo.arrivalTime} + + {voo.arrivalAirportCode}-{voo.arrivalAirport} + + + + + + + {formatDateLong(voo.departureDate)} + + + {legLabel} + + + + + {!!voo.malaLabel && ( + + Bagagem: + {voo.malaLabel} + + )} + {!!voo.class && ( + + Classe: + {voo.class} + + )} + + + + + + Mapa {voo.departureAirportCode} + + + + + ); +} + +type GroupProps = { + segmentos: (Escala | VooSegment)[]; + tipo: 'ida' | 'volta'; +}; + +export function VooCard({ segmentos, tipo }: GroupProps) { + if (!segmentos.length) return null; + const temEscalas = segmentos.length > 1; + + return ( + + {temEscalas && ( + + + + {segmentos.length - 1} {segmentos.length - 1 === 1 ? 'escala' : 'escalas'} + + + )} + + {segmentos.map((voo, idx) => ( + + + + {idx < segmentos.length - 1 && ( + + + + + + Escala em {voo.arrivalAirportCode} + + + + + )} + + ))} + + ); +} diff --git a/assets/components/reserva/formatters.ts b/assets/components/reserva/formatters.ts new file mode 100644 index 0000000..5702e8e --- /dev/null +++ b/assets/components/reserva/formatters.ts @@ -0,0 +1,30 @@ +import { API_BASE_URL } from '@/assets/config/api'; + +export const formatDateShort = (dateString: string) => { + if (!dateString) return ''; + const d = new Date(dateString); + if (Number.isNaN(d.getTime())) return dateString; + return d.toLocaleDateString('pt-PT'); +}; + +export const formatDateLong = (dateString: string) => { + if (!dateString) return ''; + const d = new Date(dateString); + if (Number.isNaN(d.getTime())) return dateString; + return d.toLocaleDateString('pt-PT', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +}; + +export const formatCurrency = (value: string) => + `${parseFloat(value || '0').toFixed(0)} EUR`; + +export const getImageUrl = (path?: string) => { + if (!path) return ''; + if (path.startsWith('http')) return path; + const baseUrl = API_BASE_URL.replace(/\/pt\/app$/, ''); + const imagePath = path.startsWith('/') ? path : `/${path}`; + return `${baseUrl}${imagePath}`; +}; diff --git a/assets/config/api.ts b/assets/config/api.ts new file mode 100644 index 0000000..ab1ae6b --- /dev/null +++ b/assets/config/api.ts @@ -0,0 +1,66 @@ +/** + * Configuração centralizada de API + * + * Este arquivo contém todas as configurações relacionadas aos endpoints da API. + * Para usar como template, basta modificar o BASE_URL e os endpoints conforme necessário. + */ + +// Endpoint base da API +export const API_BASE_URL = "https://apmtests.webclientes.com/pt/app"; +export const API_BASE_URL_DEV = "https://apmtests.webclientes.com/pt/app"; + +export const API_URL_RECUPERAR_PALAVRA_PASSE = "https://gurudasviagens.pt/pt/areareservada/recuperarPassword/"; +/** + * Endpoints da aplicação + * Adicione novos endpoints aqui conforme necessário + */ +export const API_ENDPOINTS = { + // Autenticação + USER_LOGIN: "/UserLogin", + USER_LOGOUT: "/UserLogout", + UPDATE_PROFILE: "/UserEditProfile", + + // Utilizador + USER_RESERVAS: "/getUserReservas", + USER_INFO: "/UserInfo", + GET_RESERVA: "/getReserva", + + // Contactos + CONTACTS: "/contacts", + + // Documentos + GET_DOCUMENTOS_CHECKSUMS: "/getDocumentosChecksums", + + // Conta + DELETE_ACCOUNT: "/deleteAccount", + + // Adicione mais endpoints aqui conforme necessário +} as const; + +/** + * Constrói uma URL completa combinando o BASE_URL com o endpoint + * + * @param endpoint - O endpoint a ser adicionado ao BASE_URL (ex: "/UserLogin") + * @returns A URL completa + * + * @example + * buildApiUrl(API_ENDPOINTS.USER_LOGIN) + * // Retorna: "https://finalguru.webclientes.com/pt/app/UserLogin" + */ +export function buildApiUrl(endpoint: string): string { + // Remove barras duplicadas e garante que há apenas uma barra entre base e endpoint + const base = API_BASE_URL.replace(/\/$/, ""); + const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + return `${base}${path}`; +} + +/** + * Configurações adicionais da API (timeout, headers padrão, etc.) + */ +export const API_CONFIG = { + TIMEOUT: 30000, // 30 segundos + DEFAULT_HEADERS: { + "Content-Type": "application/json", + "Accept": "application/json", + }, +} as const; diff --git a/assets/contexts/useAuth.tsx b/assets/contexts/useAuth.tsx new file mode 100644 index 0000000..5a24b16 --- /dev/null +++ b/assets/contexts/useAuth.tsx @@ -0,0 +1,222 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import CryptoJS from 'crypto-js'; +import { createContext, useContext, useEffect, useState } from 'react'; +import { API_CONFIG, API_ENDPOINTS, buildApiUrl } from '../config/api'; +import { AuthContextType, AuthProviderProps, LoginResponse, User, UserData } from '../types'; +// Contexto +const AuthContext = createContext(undefined); + +// Chaves para AsyncStorage +const STORAGE_KEYS = { + TOKEN: '@cruiseLovers:token', + USER: '@cruiseLovers:user', +} as const; + +// Provider + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [userData, setUserData] = useState(null); + // Carregar dados salvos ao iniciar + useEffect(() => { + loadStoredAuth(); + }, []); + + const loadStoredAuth = async () => { + try { + const [storedToken, storedUser] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.TOKEN), + AsyncStorage.getItem(STORAGE_KEYS.USER), + ]); + + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + } catch (err) { + console.error('Erro ao carregar autenticação:', err); + } finally { + setIsLoading(false); + } + }; + + const login = async (email: string, password: string) => { + try { + setIsLoading(true); + setError(null); + + const url = buildApiUrl(API_ENDPOINTS.USER_LOGIN); + + // Encriptar password com MD5 + const encryptedPassword = CryptoJS.MD5(password).toString(); + + // Criar FormData com os dados de login + const formData = new FormData(); + formData.append('email', email); + formData.append('password', encryptedPassword); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + // Não definir Content-Type - o React Native define automaticamente para multipart/form-data + }, + body: formData, + }); + + const data: LoginResponse = await response.json(); + + if (!response.ok || data.status !== 200) { + // A API retorna status 401 com mensagem quando não existe utilizador + throw new Error(data.message || 'Erro ao fazer login'); + } + + // Se a resposta for bem-sucedida, Guardar token e user + if (data.token) { + setToken(data.token); + await AsyncStorage.setItem(STORAGE_KEYS.TOKEN, data.token); + } + + if (data.user) { + setUser(data.user); + await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user)); + } else { + // Se não vier user na resposta, criar um objeto básico com email + setUser(userData); + await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData)); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido ao fazer login'; + setError(errorMessage); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateProfile = async (nome?: string, apelido?: string, oldPassword?: string, newPassword?: string) => { + try { + setIsLoading(true); + setError(null); + + if (!token) { + throw new Error('Não autenticado'); + } + + const url = buildApiUrl(API_ENDPOINTS.UPDATE_PROFILE); + // Criar FormData com os dados de atualização + const formData = new FormData(); + + formData.append('token', token); + + if (nome) { + formData.append('nome', nome); + } + if (apelido) { + formData.append('apelido', apelido); + } + if (newPassword) { + const encryptedNewPassword = CryptoJS.MD5(newPassword).toString(); + formData.append('password', encryptedNewPassword); + if (oldPassword) { + const encryptedOldPassword = CryptoJS.MD5(oldPassword).toString(); + formData.append('old_password', encryptedOldPassword); + } + } + + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + }, + body: formData, + }); + + const data: LoginResponse = await response.json(); + + if (!response.ok || data.status !== 200) { + throw new Error(data.message || 'Erro ao atualizar perfil'); + } + + // Atualizar dados do utilizador se vierem na resposta + if (data.user) { + setUser(data.user); + await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user)); + } else if (nome || apelido) { + const updatedUser = { ...user, nome: nome ?? user?.nome, apelido: apelido ?? user?.apelido }; + setUser(updatedUser as User); + await AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser)); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Erro desconhecido ao atualizar perfil'; + setError(errorMessage); + throw err; + } finally { + setIsLoading(false); + } + }; + + const logout = async () => { + try { + setIsLoading(true); + + // Opcional: chamar endpoint de logout na API + if (token) { + try { + const url = buildApiUrl(API_ENDPOINTS.USER_LOGOUT); + await fetch(url, { + method: 'POST', + headers: { + ...API_CONFIG.DEFAULT_HEADERS, + Authorization: `Bearer ${token}`, + }, + }); + } catch (err) { + console.error('Erro ao fazer logout na API:', err); + // Continuar mesmo se falhar + } + } + + // Limpar estado local + setUser(null); + setToken(null); + setError(null); + + // Limpar AsyncStorage + await Promise.all([ + AsyncStorage.removeItem(STORAGE_KEYS.TOKEN), + AsyncStorage.removeItem(STORAGE_KEYS.USER), + ]); + } catch (err) { + console.error('Erro ao fazer logout:', err); + } finally { + setIsLoading(false); + } + }; + + const value: AuthContextType = { + user, + token, + isAuthenticated: !!user && !!token, + isLoading, + login, + logout, + updateProfile, + error, + }; + + return {children}; +} + +// Hook para usar o contexto +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth deve ser usado dentro de um AuthProvider'); + } + return context; +} diff --git a/assets/fonts/SpaceGrotesk-Bold.ttf b/assets/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000..8a8611a Binary files /dev/null and b/assets/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/assets/fonts/SpaceGrotesk-Light.ttf b/assets/fonts/SpaceGrotesk-Light.ttf new file mode 100644 index 0000000..0f03f08 Binary files /dev/null and b/assets/fonts/SpaceGrotesk-Light.ttf differ diff --git a/assets/fonts/SpaceGrotesk-Medium.ttf b/assets/fonts/SpaceGrotesk-Medium.ttf new file mode 100644 index 0000000..e530cf8 Binary files /dev/null and b/assets/fonts/SpaceGrotesk-Medium.ttf differ diff --git a/assets/fonts/SpaceGrotesk-Regular.ttf b/assets/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000..8215f81 Binary files /dev/null and b/assets/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/assets/fonts/SpaceGrotesk-SemiBold.ttf b/assets/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 0000000..e05b967 Binary files /dev/null and b/assets/fonts/SpaceGrotesk-SemiBold.ttf differ diff --git a/assets/icons/StatusBadgeIcon.tsx b/assets/icons/StatusBadgeIcon.tsx new file mode 100644 index 0000000..eca80f5 --- /dev/null +++ b/assets/icons/StatusBadgeIcon.tsx @@ -0,0 +1,28 @@ +import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg'; +import CircleXmarkIcon from '@/assets/icons/circle-xmark-solid.svg'; +import ClockIcon from '@/assets/icons/clock-solid.svg'; +import ShipIcon from '@/assets/icons/ship-solid.svg'; +import { SvgProps } from 'react-native-svg'; + +type Props = { + statusType: string; + color: string; + size?: number; +}; + +export function StatusBadgeIcon({ statusType, color, size = 10 }: Props) { + const iconProps: SvgProps = { width: size, height: size, fill: color }; + + switch (statusType) { + case 'verde': + return ; + case 'vermelho': + return ; + case 'amarelo': + return ; + case 'roxo': + return ; + default: + return null; + } +} diff --git a/assets/icons/calendario.png b/assets/icons/calendario.png new file mode 100644 index 0000000..15f39af Binary files /dev/null and b/assets/icons/calendario.png differ diff --git a/assets/icons/chevron-right.svg b/assets/icons/chevron-right.svg new file mode 100644 index 0000000..be7b448 --- /dev/null +++ b/assets/icons/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/circle-check-solid.svg b/assets/icons/circle-check-solid.svg new file mode 100644 index 0000000..ebc4e8b --- /dev/null +++ b/assets/icons/circle-check-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/circle-xmark-solid.svg b/assets/icons/circle-xmark-solid.svg new file mode 100644 index 0000000..c74c81a --- /dev/null +++ b/assets/icons/circle-xmark-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/clock-solid.svg b/assets/icons/clock-solid.svg new file mode 100644 index 0000000..3544713 --- /dev/null +++ b/assets/icons/clock-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/confirmadas.png b/assets/icons/confirmadas.png new file mode 100644 index 0000000..8578c9d Binary files /dev/null and b/assets/icons/confirmadas.png differ diff --git a/assets/icons/contactos-fill.png b/assets/icons/contactos-fill.png new file mode 100644 index 0000000..416dd1b Binary files /dev/null and b/assets/icons/contactos-fill.png differ diff --git a/assets/icons/contactos.png b/assets/icons/contactos.png new file mode 100644 index 0000000..88a1c09 Binary files /dev/null and b/assets/icons/contactos.png differ diff --git a/assets/icons/email.png b/assets/icons/email.png new file mode 100644 index 0000000..002f1d1 Binary files /dev/null and b/assets/icons/email.png differ diff --git a/assets/icons/home-selecionado.png b/assets/icons/home-selecionado.png new file mode 100644 index 0000000..7681760 Binary files /dev/null and b/assets/icons/home-selecionado.png differ diff --git a/assets/icons/home.png b/assets/icons/home.png new file mode 100644 index 0000000..8db8499 Binary files /dev/null and b/assets/icons/home.png differ diff --git a/assets/icons/logo.png b/assets/icons/logo.png new file mode 100644 index 0000000..71f1651 Binary files /dev/null and b/assets/icons/logo.png differ diff --git a/assets/icons/logotipo-azul.png b/assets/icons/logotipo-azul.png new file mode 100644 index 0000000..bccbf33 Binary files /dev/null and b/assets/icons/logotipo-azul.png differ diff --git a/assets/icons/logotipo-branco.png b/assets/icons/logotipo-branco.png new file mode 100644 index 0000000..08387cd Binary files /dev/null and b/assets/icons/logotipo-branco.png differ diff --git a/assets/icons/logout-fill.png b/assets/icons/logout-fill.png new file mode 100644 index 0000000..303c81e Binary files /dev/null and b/assets/icons/logout-fill.png differ diff --git a/assets/icons/mail.png b/assets/icons/mail.png new file mode 100644 index 0000000..e004c2e Binary files /dev/null and b/assets/icons/mail.png differ diff --git a/assets/icons/mala.png b/assets/icons/mala.png new file mode 100644 index 0000000..28526bf Binary files /dev/null and b/assets/icons/mala.png differ diff --git a/assets/icons/perfil-apagar-fill.png b/assets/icons/perfil-apagar-fill.png new file mode 100644 index 0000000..74fcd82 Binary files /dev/null and b/assets/icons/perfil-apagar-fill.png differ diff --git a/assets/icons/perfil-fill.png b/assets/icons/perfil-fill.png new file mode 100644 index 0000000..0fafdca Binary files /dev/null and b/assets/icons/perfil-fill.png differ diff --git a/assets/icons/perfil-selecionado.png b/assets/icons/perfil-selecionado.png new file mode 100644 index 0000000..4c60004 Binary files /dev/null and b/assets/icons/perfil-selecionado.png differ diff --git a/assets/icons/perfil.png b/assets/icons/perfil.png new file mode 100644 index 0000000..02553f0 Binary files /dev/null and b/assets/icons/perfil.png differ diff --git a/assets/icons/plane-arrival-solid-full.svg b/assets/icons/plane-arrival-solid-full.svg new file mode 100644 index 0000000..438ed34 --- /dev/null +++ b/assets/icons/plane-arrival-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/plane-departure-solid-full.svg b/assets/icons/plane-departure-solid-full.svg new file mode 100644 index 0000000..387f258 --- /dev/null +++ b/assets/icons/plane-departure-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/right-from-bracket-solid-full.svg b/assets/icons/right-from-bracket-solid-full.svg new file mode 100644 index 0000000..d04a807 --- /dev/null +++ b/assets/icons/right-from-bracket-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/seta-up-fill.png b/assets/icons/seta-up-fill.png new file mode 100644 index 0000000..86714ae Binary files /dev/null and b/assets/icons/seta-up-fill.png differ diff --git a/assets/icons/seta-up.png b/assets/icons/seta-up.png new file mode 100644 index 0000000..6bfd174 Binary files /dev/null and b/assets/icons/seta-up.png differ diff --git a/assets/icons/ship-solid.svg b/assets/icons/ship-solid.svg new file mode 100644 index 0000000..23dd2b3 --- /dev/null +++ b/assets/icons/ship-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/telefone.png b/assets/icons/telefone.png new file mode 100644 index 0000000..9e1fc45 Binary files /dev/null and b/assets/icons/telefone.png differ diff --git a/assets/icons/user-solid-full.svg b/assets/icons/user-solid-full.svg new file mode 100644 index 0000000..12c4a6e --- /dev/null +++ b/assets/icons/user-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/user-xmark-solid-full.svg b/assets/icons/user-xmark-solid-full.svg new file mode 100644 index 0000000..264dc78 --- /dev/null +++ b/assets/icons/user-xmark-solid-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/whatsapp.png b/assets/icons/whatsapp.png new file mode 100644 index 0000000..962d3a0 Binary files /dev/null and b/assets/icons/whatsapp.png differ diff --git a/assets/images/android-icon-background.png b/assets/images/android-icon-background.png deleted file mode 100644 index 5ffefc5..0000000 Binary files a/assets/images/android-icon-background.png and /dev/null differ diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png deleted file mode 100644 index 3a9e501..0000000 Binary files a/assets/images/android-icon-foreground.png and /dev/null differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png deleted file mode 100644 index 77484eb..0000000 Binary files a/assets/images/android-icon-monochrome.png and /dev/null differ diff --git a/assets/images/banner-login.png b/assets/images/banner-login.png new file mode 100644 index 0000000..f0b9adc Binary files /dev/null and b/assets/images/banner-login.png differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png deleted file mode 100644 index 408bd74..0000000 Binary files a/assets/images/favicon.png and /dev/null differ diff --git a/assets/images/icon.png b/assets/images/icon.png deleted file mode 100644 index 7165a53..0000000 Binary files a/assets/images/icon.png and /dev/null differ diff --git a/assets/images/partial-react-logo.png b/assets/images/partial-react-logo.png deleted file mode 100644 index 66fd957..0000000 Binary files a/assets/images/partial-react-logo.png and /dev/null differ diff --git a/assets/images/react-logo.png b/assets/images/react-logo.png deleted file mode 100644 index 9d72a9f..0000000 Binary files a/assets/images/react-logo.png and /dev/null differ diff --git a/assets/images/react-logo@2x.png b/assets/images/react-logo@2x.png deleted file mode 100644 index 2229b13..0000000 Binary files a/assets/images/react-logo@2x.png and /dev/null differ diff --git a/assets/images/react-logo@3x.png b/assets/images/react-logo@3x.png deleted file mode 100644 index a99b203..0000000 Binary files a/assets/images/react-logo@3x.png and /dev/null differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png deleted file mode 100644 index 03d6f6b..0000000 Binary files a/assets/images/splash-icon.png and /dev/null differ diff --git a/assets/services/documentSync.ts b/assets/services/documentSync.ts new file mode 100644 index 0000000..e0013ef --- /dev/null +++ b/assets/services/documentSync.ts @@ -0,0 +1,146 @@ +import { buildApiUrl, API_BASE_URL, API_ENDPOINTS } from "../config/api"; +import { DocumentoChecksum, DocumentosChecksumsResponse, ReservaDocumentos } from "../types"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as FileSystem from "expo-file-system/legacy"; + +const DOCUMENTOS_DIR = `${FileSystem.documentDirectory}documentos/`; + +// Devolve o caminho local de um documento +const getLocalPath = (idDocumento: string, caminhoFicheiro: string): string => { + const fileName = caminhoFicheiro.split("/").pop() || `documento_${idDocumento}.pdf`; + return `${DOCUMENTOS_DIR}${idDocumento}_${fileName}`; +}; + +// Garante que o diretório de documentos existe +const ensureDir = async (): Promise => { + const info = await FileSystem.getInfoAsync(DOCUMENTOS_DIR); + if (!info.exists) { + await FileSystem.makeDirectoryAsync(DOCUMENTOS_DIR, { intermediates: true }); + } +}; + +// Constrói URL completa a partir de um caminho relativo +const buildUrl = (path: string | null | undefined): string => { + if (!path) return ""; + if (path.startsWith("http")) return path; + const base = API_BASE_URL.replace(/\/pt\/app$/, ""); + return `${base}${path.startsWith("/") ? path : `/${path}`}`; +}; + +// Checksum guardado localmente para um documento +export const getStoredChecksum = async (idDocumento: string): Promise => { + try { + return await AsyncStorage.getItem(`@cruiseLovers:documento:${idDocumento}:checksum`); + } catch { + return null; + } +}; + +// Guarda o checksum localmente +export const saveStoredChecksum = async (idDocumento: string, checksum: string): Promise => { + try { + await AsyncStorage.setItem(`@cruiseLovers:documento:${idDocumento}:checksum`, checksum); + } catch { + // silencioso + } +}; + +/** + * Devolve o URI local de um documento se este já estiver descarregado, ou null caso contrário. + */ +export const getLocalDocumentUri = async (idDocumento: string, caminhoFicheiro: string): Promise => { + try { + const localPath = getLocalPath(idDocumento, caminhoFicheiro); + const info = await FileSystem.getInfoAsync(localPath); + return info.exists ? localPath : null; + } catch { + return null; + } +}; + +// Faz download de um único documento +export const downloadDocumento = async (documento: DocumentoChecksum): Promise => { + try { + const url = buildUrl(documento.caminhoFicheiro); + if (!url) return null; + + await ensureDir(); + + const localPath = getLocalPath(documento.idDocumento, documento.caminhoFicheiro); + + const result = await FileSystem.downloadAsync(url, localPath); + + if (result.status < 200 || result.status >= 300) { + console.error(`Download falhou com status ${result.status}`); + return null; + } + + if (documento.checksum) { + await saveStoredChecksum(documento.idDocumento, documento.checksum); + } + + return localPath; + } catch (error) { + console.error("Erro ao fazer download:", error); + return null; + } +}; + +// Busca checksums do servidor +export const getDocumentosChecksums = async (token: string): Promise => { + try { + const formData = new FormData(); + formData.append("token", token); + + const response = await fetch(buildApiUrl(API_ENDPOINTS.GET_DOCUMENTOS_CHECKSUMS), { + method: "POST", + headers: { Accept: "application/json" }, + body: formData, + }); + + return (await response.json()) as DocumentosChecksumsResponse; + } catch (error) { + console.error("Erro ao buscar checksums de documentos:", error); + return null; + } +}; + +// Verifica quais documentos precisam ser (re)descarregados +export const verificarDocumentosParaDownload = async ( + reservaDocumentos: ReservaDocumentos[] +): Promise => { + const para: DocumentoChecksum[] = []; + + for (const reserva of reservaDocumentos) { + for (const doc of reserva.documentos) { + if (!doc.caminhoFicheiro || !doc.checksum) continue; + + const storedChecksum = await getStoredChecksum(doc.idDocumento); + const localPath = getLocalPath(doc.idDocumento, doc.caminhoFicheiro); + const info = await FileSystem.getInfoAsync(localPath); + + if (doc.checksum !== storedChecksum || !info.exists) { + para.push(doc); + } + } + } + + return para; +}; + +// Descarrega múltiplos documentos +export const downloadDocumentos = async ( + documentos: DocumentoChecksum[], + onProgress?: (current: number, total: number) => void +): Promise<{ sucesso: number; falhas: number }> => { + let sucesso = 0; + let falhas = 0; + + for (let i = 0; i < documentos.length; i++) { + if (onProgress) onProgress(i + 1, documentos.length); + const resultado = await downloadDocumento(documentos[i]); + resultado ? sucesso++ : falhas++; + } + + return { sucesso, falhas }; +}; diff --git a/assets/services/offlineStorage.ts b/assets/services/offlineStorage.ts new file mode 100644 index 0000000..6e11097 --- /dev/null +++ b/assets/services/offlineStorage.ts @@ -0,0 +1,229 @@ +import { ContactData, Documento, Pagamento, Reserva, ReservaData, SocialMedia } from "../types"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const STORAGE_KEYS = { + RESERVAS: "@cruiseLovers:reservas", + RESERVA_DETAIL: "@cruiseLovers:reserva:", + RESERVA_FULL: "@cruiseLovers:reservaFull:", + CONTACTS: "@cruiseLovers:contacts", + SOCIALS: "@cruiseLovers:socials", + USER_INFO: "@cruiseLovers:userInfo", + LAST_SYNC: "@cruiseLovers:lastSync", + CACHE_VERSION: "@cruiseLovers:cacheVersion", +} as const; + +export interface CachedReservaFull { + reservaData: ReservaData; + pagamentos: Pagamento[]; + documentos: Documento[]; + cachedAt: string; +} + +const CACHE_VERSION = "1.0.0"; + +/** + * Armazena a lista de reservas no cache + */ +export const cacheReservas = async (reservas: Reserva[]): Promise => { + try { + await AsyncStorage.setItem(STORAGE_KEYS.RESERVAS, JSON.stringify(reservas)); + await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString()); + } catch (error) { + console.error("Erro ao armazenar reservas no cache:", error); + } +}; + +/** + * Obtém a lista de reservas do cache + */ +export const getCachedReservas = async (): Promise => { + try { + const cached = await AsyncStorage.getItem(STORAGE_KEYS.RESERVAS); + if (cached) { + return JSON.parse(cached); + } + return null; + } catch (error) { + console.error("Erro ao obter reservas do cache:", error); + return null; + } +}; + +/** + * Armazena os detalhes de uma reserva no cache + */ +export const cacheReservaDetail = async (id: string, reservaData: ReservaData): Promise => { + try { + const key = `${STORAGE_KEYS.RESERVA_DETAIL}${id}`; + await AsyncStorage.setItem(key, JSON.stringify(reservaData)); + } catch (error) { + console.error(`Erro ao armazenar detalhes da reserva ${id} no cache:`, error); + } +}; + +/** + * Obtém os detalhes de uma reserva do cache + */ +export const getCachedReservaDetail = async (id: string): Promise => { + try { + const key = `${STORAGE_KEYS.RESERVA_DETAIL}${id}`; + const cached = await AsyncStorage.getItem(key); + if (cached) { + return JSON.parse(cached); + } + return null; + } catch (error) { + console.error(`Erro ao obter detalhes da reserva ${id} do cache:`, error); + return null; + } +}; + +/** + * Armazena os dados completos de uma reserva (reserva + pagamentos + documentos) no cache + */ +export const cacheReservaFull = async (referencia: string, data: CachedReservaFull): Promise => { + try { + const key = `${STORAGE_KEYS.RESERVA_FULL}${referencia}`; + await AsyncStorage.setItem(key, JSON.stringify(data)); + } catch (error) { + console.error(`Erro ao armazenar dados completos da reserva ${referencia} no cache:`, error); + } +}; + +/** + * Obtém os dados completos de uma reserva do cache + */ +export const getCachedReservaFull = async (referencia: string): Promise => { + try { + const key = `${STORAGE_KEYS.RESERVA_FULL}${referencia}`; + const cached = await AsyncStorage.getItem(key); + return cached ? (JSON.parse(cached) as CachedReservaFull) : null; + } catch (error) { + console.error(`Erro ao obter dados completos da reserva ${referencia} do cache:`, error); + return null; + } +}; + +/** + * Armazena os contactos no cache + */ +export const cacheContacts = async (contactData: ContactData, socials: SocialMedia[]): Promise => { + try { + await AsyncStorage.setItem(STORAGE_KEYS.CONTACTS, JSON.stringify(contactData)); + await AsyncStorage.setItem(STORAGE_KEYS.SOCIALS, JSON.stringify(socials)); + await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString()); + } catch (error) { + console.error("Erro ao armazenar contactos no cache:", error); + } +}; + +/** + * Obtém os contactos do cache + */ +export const getCachedContacts = async (): Promise<{ contactData: ContactData | null; socials: SocialMedia[] }> => { + try { + const cachedContact = await AsyncStorage.getItem(STORAGE_KEYS.CONTACTS); + const cachedSocials = await AsyncStorage.getItem(STORAGE_KEYS.SOCIALS); + + return { + contactData: cachedContact ? JSON.parse(cachedContact) : null, + socials: cachedSocials ? JSON.parse(cachedSocials) : [], + }; + } catch (error) { + console.error("Erro ao obter contactos do cache:", error); + return { contactData: null, socials: [] }; + } +}; + +/** + * Obtém a data da última sincronização + */ +export const getLastSyncDate = async (): Promise => { + try { + const lastSync = await AsyncStorage.getItem(STORAGE_KEYS.LAST_SYNC); + return lastSync ? new Date(lastSync) : null; + } catch { + return null; + } +}; + +/** + * Limpa todo o cache + */ +export const clearCache = async (): Promise => { + try { + const keys = await AsyncStorage.getAllKeys(); + const appKeys = keys.filter(key => key.startsWith("@cruiseLovers:")); + await AsyncStorage.multiRemove(appKeys); + } catch (error) { + console.error("Erro ao limpar cache:", error); + } +}; + +/** + * Limpa apenas o cache de reservas + */ +export const clearReservasCache = async (): Promise => { + try { + const keys = await AsyncStorage.getAllKeys(); + const reservaKeys = keys.filter(key => + key === STORAGE_KEYS.RESERVAS || + key.startsWith(STORAGE_KEYS.RESERVA_DETAIL) + ); + await AsyncStorage.multiRemove(reservaKeys); + } catch (error) { + console.error("Erro ao limpar cache de reservas:", error); + } +}; + +/** + * Armazena informações do perfil no cache + */ +export const cacheUserInfo = async (userInfo: any): Promise => { + try { + await AsyncStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(userInfo)); + } catch (error) { + console.error("Erro ao armazenar perfil no cache:", error); + } +}; + +/** + * Obtém informações do perfil do cache + */ +export const getCachedUserInfo = async (): Promise => { + try { + const cached = await AsyncStorage.getItem(STORAGE_KEYS.USER_INFO); + return cached ? JSON.parse(cached) : null; + } catch (error) { + console.error("Erro ao obter perfil do cache:", error); + return null; + } +}; + +/** + * Obtém informações sobre o tamanho do cache + */ +export const getCacheInfo = async (): Promise<{ size: number; lastSync: Date | null }> => { + try { + const keys = await AsyncStorage.getAllKeys(); + const appKeys = keys.filter(key => key.startsWith("@cruiseLovers:")); + const items = await AsyncStorage.multiGet(appKeys); + + let totalSize = 0; + items.forEach(([_, value]) => { + if (value) { + totalSize += value.length; + } + }); + + const lastSync = await getLastSyncDate(); + + return { + size: totalSize, + lastSync, + }; + } catch (error) { + console.error("Erro ao obter informações do cache:", error); + return { size: 0, lastSync: null }; + } +}; diff --git a/assets/styles/colors.tsx b/assets/styles/colors.tsx new file mode 100644 index 0000000..d6f2a91 --- /dev/null +++ b/assets/styles/colors.tsx @@ -0,0 +1,31 @@ +import { rgbaColor } from "react-native-reanimated/src/Colors"; + +export const colors = { + azul: '#123572', + azul_text: '#00235A', + azul_bg: 'rgba(0, 35, 90, 0.7)', + azul_escuro: 'rgba(0, 9, 25, 0.85)', + vermelho: '#EB2415', + + background_1: '#F4F7FE', + background_2: '#EFEFEF', + + cinza: '#C7C7C7', + + branco: '#ffffff', + + success: '#36E25D', + successAccent: '#D7FFE0', + + pendente: '#CB36E2', + pendenteAccent: '#EB2415', + + errorAccent: '#CB36E2', + error: '#F74A3D', + + stars: '#F8D23A', +}; + + + + diff --git a/assets/styles/fonts.tsx b/assets/styles/fonts.tsx new file mode 100644 index 0000000..7a55b39 --- /dev/null +++ b/assets/styles/fonts.tsx @@ -0,0 +1,7 @@ +export const fonts = { + regular: 'SpaceGrotesk-Regular', + medium: 'SpaceGrotesk-Medium', + bold: 'SpaceGrotesk-Bold', + semiBold: 'SpaceGrotesk-SemiBold', + light: 'SpaceGrotesk-Light', +}; \ No newline at end of file diff --git a/assets/types/index.tsx b/assets/types/index.tsx new file mode 100644 index 0000000..7b037e5 --- /dev/null +++ b/assets/types/index.tsx @@ -0,0 +1,345 @@ +import { ReactNode } from 'react'; + +// ============================================ +// TIPOS DE AUTENTICAÇÃO +// ============================================ + +export interface User { + email: string; + nome: string; + apelido: string; + contacto: string; + morada: string; + nif: string; + id_user?: string; + [key: string]: any; +} + +export interface AuthContextType { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + updateProfile: (nome?: string, apelido?: string, oldPassword?: string, newPassword?: string) => Promise; + error: string | null; +} + +export interface LoginResponse { + status: number; + message?: string; + token?: string; + user?: User; + [key: string]: any; +} + +export interface UserData { + email: string; + nome: string; + apelido: string; + contacto: string; + morada: string; + nif: string; +} + +export interface AuthProviderProps { + children: ReactNode; +} + +// ============================================ +// TIPOS DE RESERVAS +// ============================================ + +export interface Reserva { + destino: string; + referenciaAgencia: string; + referenciaViagem: string; + startDate: string; + status: string; + statusCode: string; + imagemCidade: string; + pais: string; +} + +export interface ReservasResponse { + status: string | number; + user?: User; + message?: string; + reservas: Reserva[]; +} + +export interface HotelFoto { + src: string; + srcZoom: string; + alt: string; +} + +export interface Passageiro { + nome: string; + sobrenome: string; + genero: string; + dataNascimento: string; + nacionalidade: string; + morada: string; + paisEmissao: string; + numeroDocumento: string; + dataDeEmissao: string; + dataDeValidade: string; + telemovel: string; + email: string; +} + +export interface HotelInfo { + name: string; + data: string; + noites: number; + regime: string; + quarto: string; + id_hotel: string; + imagemexterna: string; + imageminterna: string; + stars: string; +} + +export interface Escala { + companyCode: string; + company: string; + number: string; + departureAirportCode: string; + mapaAirportDeparture?: string; + departureAirport: string; + departureDateTime: string; + departureDate: string; + departureTime: string; + arrivalAirportCode: string; + mapaAirportArrival?: string; + arrivalAirport: string; + arrivalDateTime: string; + arrivalDate: string; + arrivalTime: string; + flightTime: string; + mala: boolean; + malaLabel: string; + class: string; + infoAeroportoDeparture?: InfoAeroporto; + infoAeroportoArrival?: InfoAeroporto; + +} + +export interface VooSegment { + departureDate: string; + departureTime: string; + arrivalDate: string; + arrivalTime: string; + number: string; + class: string; + malaLabel: string; + departureAirportCode: string; + arrivalAirportCode: string; + departureAirport: string; + arrivalAirport: string; + departureAirportTimeZone: string; + arrivalAirportTimeZone: string; + flightTime: string; +} + +export interface VooLeg { + departureDate: string; + infoEscalas: Escala[]; + infoAeroportoDeparture?: InfoAeroporto; + infoAeroportoArrival?: InfoAeroporto; +} + +export interface InfoAeroporto { + name: string; + timezone: string; + iso_country?: string; + gps: { + lng: number | string; + lat: number | string; + }; +} + +export interface Extra { + name: string; + price: number; +} + +export type VooDirection = VooLeg | VooSegment[]; + +export interface JsonData { + hotel: HotelInfo[]; + voo: { + departure: VooDirection; + arrival: VooDirection; + }; + extra?: Extra[]; + extras?: Extra[]; +} + +export interface Cidade { + id_cidade: string; + cod_pais: string; + imagem: string; + imagem_banner: string; + thumbnail: string; + destaque: string; + destaque_top: string; + outros_destinos: string; + lat: string; + lng: string; + zoom: string; + ordem: string; + reference: string; + ordemFooter: string; + html_image_social: string; + activofooter: string; + activo: string; + cod_tag: string; + categoria: string; + lowAeroporto: string; + lowData: string; + lowNoites: string; + lowRegimeKey: string; + lowPreco: string; + imagemMenu: string; +} + +export interface ReservaData { + id_reserva: string; + destino: string; + startDate: string; + endDate: string; + referenciaViagem: string; + referenciaAgencia: string; + localizador: string; + noites: string; + status: string; + pessoas: string; + adultos: string; + criancas: string; + quartos: string; + jsonData: string | JsonData; + precoTotalFinal: string; + valorPago: string; + valorAPagar: string; + operador: string; + linkTransfers?: string; + cidade: Cidade | null; + imagemCidade?: string; + infoCidade: string; + passageiros: Passageiro[]; + quartosPassageiros?: QuartoPassageiro[]; +} + +export interface QuartoPassageiro { + cod_user_api: string; + idade: string; +} + +export interface ReservaResponse { + status: number; + message?: string; + reserva?: ReservaData; + pagamentos?: Pagamento[]; + documentos?: Documento[]; +} + +export interface Pagamento { + valor: string; + estado: string; + metodoPagamento: string; + data_hora: string; + data_pagamento: string; +} + +export interface Documento { + nome: string; + idDocumento: string; + caminhoFicheiro: string; + checksum: string | null; +} + +// ============================================ +// TIPOS DE PERFIL +// ============================================ + +export interface UserInfo { + nome: string; + apelido: string; + email: string; +} + +export interface UserInfoResponse { + status: number; + message?: string; + user?: UserInfo; +} + +// ============================================ +// TIPOS DE CONTACTOS +// ============================================ + +export interface ContactData { + email: string; + telephone: string; + mobilePhone: string; + address: string; + horarios: string; + whatsapp: string | null; + emergencyPhone: string | null; + coordenadas?: string | null; +} + +export interface SocialMedia { + key: string; + title: string; + value: string; +} + +export interface ContactResponse { + status: number; + message?: string; + contact?: ContactData; + socials?: SocialMedia[]; +} + +// ============================================ +// TIPOS DE COMPONENTES +// ============================================ + +export interface CardDestinoProps { + destino: string; + referenciaAgencia: string; + referenciaViagem: string; + startDate: string; + status: string; + statusCode: string; + imagemCidade: string; + imageUrl: string; + color: string; + textColor: string; +} + +// ============================================ +// TIPOS DE DOCUMENTOS CHECKSUMS +// ============================================ + +export interface DocumentoChecksum { + idDocumento: string; + checksum: string; + nome: string; + caminhoFicheiro: string; +} + +export interface ReservaDocumentos { + referenciaViagem: string; + documentos: DocumentoChecksum[]; +} + +export interface DocumentosChecksumsResponse { + status: number; + message?: string; + documentos: ReservaDocumentos[]; +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..d872de3 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; diff --git a/components/external-link.tsx b/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/components/haptic-tab.tsx b/components/haptic-tab.tsx deleted file mode 100644 index 7f3981c..0000000 --- a/components/haptic-tab.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; -import { PlatformPressable } from '@react-navigation/elements'; -import * as Haptics from 'expo-haptics'; - -export function HapticTab(props: BottomTabBarButtonProps) { - return ( - { - if (process.env.EXPO_OS === 'ios') { - // Add a soft haptic feedback when pressing down on the tabs. - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - props.onPressIn?.(ev); - }} - /> - ); -} diff --git a/components/hello-wave.tsx b/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/components/parallax-scroll-view.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollOffset, -} from 'react-native-reanimated'; - -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; - -const HEADER_HEIGHT = 250; - -type Props = PropsWithChildren<{ - headerImage: ReactElement; - headerBackgroundColor: { dark: string; light: string }; -}>; - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; - const scrollRef = useAnimatedRef(); - const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); - - return ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/components/themed-text.tsx b/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/components/themed-view.tsx b/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx deleted file mode 100644 index 6345fde..0000000 --- a/components/ui/collapsible.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; - - return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/components/ui/icon-symbol.ios.tsx b/components/ui/icon-symbol.ios.tsx deleted file mode 100644 index 9177f4d..0000000 --- a/components/ui/icon-symbol.ios.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; -import { StyleProp, ViewStyle } from 'react-native'; - -export function IconSymbol({ - name, - size = 24, - color, - style, - weight = 'regular', -}: { - name: SymbolViewProps['name']; - size?: number; - color: string; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ( - - ); -} diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx deleted file mode 100644 index b7ece6b..0000000 --- a/components/ui/icon-symbol.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Fallback for using MaterialIcons on Android and web. - -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; -import { ComponentProps } from 'react'; -import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; - -type IconMapping = Record['name']>; -type IconSymbolName = keyof typeof MAPPING; - -/** - * Add your SF Symbols to Material Icons mappings here. - * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). - * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. - */ -const MAPPING = { - 'house.fill': 'home', - 'paperplane.fill': 'send', - 'chevron.left.forwardslash.chevron.right': 'code', - 'chevron.right': 'chevron-right', -} as IconMapping; - -/** - * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. - * This ensures a consistent look across platforms, and optimal resource usage. - * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. - */ -export function IconSymbol({ - name, - size = 24, - color, - style, -}: { - name: IconSymbolName; - size?: number; - color: string | OpaqueColorValue; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ; -} diff --git a/constants/theme.ts b/constants/theme.ts deleted file mode 100644 index f06facd..0000000 --- a/constants/theme.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. - */ - -import { Platform } from 'react-native'; - -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; - -export const Colors = { - light: { - text: '#11181C', - background: '#fff', - tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', - tabIconSelected: tintColorLight, - }, - dark: { - text: '#ECEDEE', - background: '#151718', - tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', - tabIconSelected: tintColorDark, - }, -}; - -export const Fonts = Platform.select({ - ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', - }, - default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', - }, - web: { - sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", - serif: "Georgia, 'Times New Roman', serif", - rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif", - mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", - }, -}); diff --git a/hooks/use-color-scheme.ts b/hooks/use-color-scheme.ts deleted file mode 100644 index 17e3c63..0000000 --- a/hooks/use-color-scheme.ts +++ /dev/null @@ -1 +0,0 @@ -export { useColorScheme } from 'react-native'; diff --git a/hooks/use-color-scheme.web.ts b/hooks/use-color-scheme.web.ts deleted file mode 100644 index 7eb1c1b..0000000 --- a/hooks/use-color-scheme.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme as useRNColorScheme } from 'react-native'; - -/** - * To support static rendering, this value needs to be re-calculated on the client side for web - */ -export function useColorScheme() { - const [hasHydrated, setHasHydrated] = useState(false); - - useEffect(() => { - setHasHydrated(true); - }, []); - - const colorScheme = useRNColorScheme(); - - if (hasHydrated) { - return colorScheme; - } - - return 'light'; -} diff --git a/hooks/use-theme-color.ts b/hooks/use-theme-color.ts deleted file mode 100644 index 0cbc3a6..0000000 --- a/hooks/use-theme-color.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Learn more about light and dark modes: - * https://docs.expo.dev/guides/color-schemes/ - */ - -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function useThemeColor( - props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark -) { - const theme = useColorScheme() ?? 'light'; - const colorFromProps = props[theme]; - - if (colorFromProps) { - return colorFromProps; - } else { - return Colors[theme][colorName]; - } -} diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..d4f7dfc --- /dev/null +++ b/metro.config.js @@ -0,0 +1,18 @@ +const { getDefaultConfig } = require('expo/metro-config'); + +module.exports = (() => { + const config = getDefaultConfig(__dirname); + const { transformer, resolver } = config; + + config.transformer = { + ...transformer, + babelTransformerPath: require.resolve('react-native-svg-transformer/expo'), + }; + config.resolver = { + ...resolver, + assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'), + sourceExts: [...resolver.sourceExts, 'svg'], + }; + + return config; +})(); diff --git a/package-lock.json b/package-lock.json index facf52d..e069358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,22 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "crypto-js": "^4.2.0", "expo": "~54.0.33", + "expo-clipboard": "^56.0.3", "expo-constants": "~18.0.13", + "expo-file-system": "^56.0.7", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", - "expo-linking": "~8.0.11", + "expo-linking": "~8.0.12", + "expo-maps": "^56.0.6", "expo-router": "~6.0.23", + "expo-sharing": "^56.0.13", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -31,13 +37,17 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", "react-native-web": "~0.21.0", + "react-native-webview": "^13.16.1", "react-native-worklets": "0.5.1" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", + "react-native-svg-transformer": "^1.5.3", "typescript": "~5.9.2" } }, @@ -1486,7 +1496,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2787,6 +2796,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -3153,6 +3174,254 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3205,6 +3474,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4578,6 +4854,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5070,6 +5352,33 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -5093,6 +5402,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-in-js-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", @@ -5102,6 +5417,92 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5318,6 +5719,72 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -5387,6 +5854,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -5396,6 +5875,23 @@ "node": ">=8" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -5811,6 +6307,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6088,6 +6585,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.3.tgz", + "integrity": "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -6104,9 +6612,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.22", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", - "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", + "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6179,6 +6687,17 @@ "react-native": "*" } }, + "node_modules/expo-maps": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/expo-maps/-/expo-maps-56.0.6.tgz", + "integrity": "sha512-oCivL/WGeNzhDb6vGoAHbzL9QkaqiBh6GMtgvHdlwiOx6kS6YE+/bUeg7D1072qsPIT6+UYz1yt90+MM1eXJRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -6470,6 +6989,101 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.13.tgz", + "integrity": "sha512-Yz6mBSqbU5dM6UzCjkKr1+B0JKADRuGj4Dokgs2fLysRTyjvO4mbmSTyY7AGQx3VYz0IUMoyPg4zqvsWPEFeDg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/plist": "^0.7.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-plugins": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", + "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^56.0.5", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-types": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", + "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", + "license": "MIT" + }, + "node_modules/expo-sharing/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/expo-sharing/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-sharing/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-sharing/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", @@ -6649,6 +7263,16 @@ "node": ">=8" } }, + "node_modules/expo/node_modules/expo-file-system": { + "version": "19.0.22", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", + "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -7872,6 +8496,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8319,6 +8952,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8822,6 +9462,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8856,12 +9506,30 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9361,6 +10029,17 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -9457,6 +10136,18 @@ "node": ">=10" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -9853,6 +10544,25 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-png": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", @@ -9874,6 +10584,13 @@ "node": ">= 0.8" } }, + "node_modules/path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9932,6 +10649,16 @@ "node": "20 || >=22" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10371,7 +11098,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "^7.7.2" @@ -10421,6 +11147,39 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg-transformer": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-native-svg-transformer/-/react-native-svg-transformer-1.5.3.tgz", + "integrity": "sha512-M4uFg5pUt35OMgjD4rWWbwd6PmxV96W7r/gQTTa+iZA5B+jO6aURhzAZGLHSrg1Kb91cKG0Rildy9q1WJvYstg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", + "path-dirname": "^1.0.2" + }, + "peerDependencies": { + "react-native": ">=0.59.0", + "react-native-svg": ">=12.0.0" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -10454,6 +11213,21 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "license": "MIT", + "peer": true, + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", @@ -11336,6 +12110,17 @@ "node": ">=8.0.0" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -11702,6 +12487,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/svgo/node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", diff --git a/package.json b/package.json index 393f357..8b5111c 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,22 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "crypto-js": "^4.2.0", "expo": "~54.0.33", + "expo-clipboard": "^56.0.3", "expo-constants": "~18.0.13", + "expo-file-system": "^56.0.7", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", - "expo-linking": "~8.0.11", + "expo-linking": "~8.0.12", + "expo-maps": "^56.0.6", "expo-router": "~6.0.23", + "expo-sharing": "^56.0.13", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -31,17 +37,21 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-worklets": "0.5.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-web": "~0.21.0" + "react-native-svg": "15.12.1", + "react-native-web": "~0.21.0", + "react-native-webview": "^13.16.1", + "react-native-worklets": "0.5.1" }, "devDependencies": { + "@types/crypto-js": "^4.2.2", "@types/react": "~19.1.0", - "typescript": "~5.9.2", "eslint": "^9.25.0", - "eslint-config-expo": "~10.0.0" + "eslint-config-expo": "~10.0.0", + "react-native-svg-transformer": "^1.5.3", + "typescript": "~5.9.2" }, "private": true } diff --git a/scripts/reset-project.js b/scripts/reset-project.js deleted file mode 100755 index 51dff15..0000000 --- a/scripts/reset-project.js +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node - -/** - * This script is used to reset the project to a blank state. - * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file. - * You can remove the `reset-project` script from package.json and safely delete this file after running it. - */ - -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); - -const root = process.cwd(); -const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; -const exampleDir = "app-example"; -const newAppDir = "app"; -const exampleDirPath = path.join(root, exampleDir); - -const indexContent = `import { Text, View } from "react-native"; - -export default function Index() { - return ( - - Edit app/index.tsx to edit this screen. - - ); -} -`; - -const layoutContent = `import { Stack } from "expo-router"; - -export default function RootLayout() { - return ; -} -`; - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -const moveDirectories = async (userInput) => { - try { - if (userInput === "y") { - // Create the app-example directory - await fs.promises.mkdir(exampleDirPath, { recursive: true }); - console.log(`📁 /${exampleDir} directory created.`); - } - - // Move old directories to new app-example directory or delete them - for (const dir of oldDirs) { - const oldDirPath = path.join(root, dir); - if (fs.existsSync(oldDirPath)) { - if (userInput === "y") { - const newDirPath = path.join(root, exampleDir, dir); - await fs.promises.rename(oldDirPath, newDirPath); - console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); - } else { - await fs.promises.rm(oldDirPath, { recursive: true, force: true }); - console.log(`❌ /${dir} deleted.`); - } - } else { - console.log(`➡️ /${dir} does not exist, skipping.`); - } - } - - // Create new /app directory - const newAppDirPath = path.join(root, newAppDir); - await fs.promises.mkdir(newAppDirPath, { recursive: true }); - console.log("\n📁 New /app directory created."); - - // Create index.tsx - const indexPath = path.join(newAppDirPath, "index.tsx"); - await fs.promises.writeFile(indexPath, indexContent); - console.log("📄 app/index.tsx created."); - - // Create _layout.tsx - const layoutPath = path.join(newAppDirPath, "_layout.tsx"); - await fs.promises.writeFile(layoutPath, layoutContent); - console.log("📄 app/_layout.tsx created."); - - console.log("\n✅ Project reset complete. Next steps:"); - console.log( - `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${ - userInput === "y" - ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` - : "" - }` - ); - } catch (error) { - console.error(`❌ Error during script execution: ${error.message}`); - } -}; - -rl.question( - "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ", - (answer) => { - const userInput = answer.trim().toLowerCase() || "y"; - if (userInput === "y" || userInput === "n") { - moveDirectories(userInput).finally(() => rl.close()); - } else { - console.log("❌ Invalid input. Please enter 'Y' or 'N'."); - rl.close(); - } - } -); diff --git a/styles/screens/auth/login.styles.ts b/styles/screens/auth/login.styles.ts new file mode 100644 index 0000000..c748776 --- /dev/null +++ b/styles/screens/auth/login.styles.ts @@ -0,0 +1,133 @@ +import { StyleSheet } from 'react-native'; +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + scrollContent: { + flexGrow: 1, + paddingBottom: 40, + }, + hero: { + height: 380, + justifyContent: 'flex-end', + }, + heroImage: { + width: '100%', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.azul_escuro, + opacity: 0.9, + }, + heroContent: { + zIndex: 1, + alignItems: 'center', + paddingHorizontal: 18, + paddingBottom: 100, + }, + logo: { + width: 250, + height: 50, + marginBottom: 18, + }, + title: { + color: colors.branco, + fontSize: 36, + fontFamily: fonts.bold, + marginBottom: 6, + }, + subtitle: { + color: colors.branco, + fontSize: 18, + lineHeight: 29, + textAlign: 'center', + fontFamily: fonts.regular, + }, + formCard: { + marginHorizontal: 24, + marginTop: -62, + backgroundColor: colors.branco, + borderRadius: 18, + paddingHorizontal: 18, + paddingVertical: 20, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 18, + shadowOffset: { width: 0, height: 5 }, + elevation: 4, + }, + label: { + color: colors.azul_text, + fontSize: 15, + marginBottom: 8, + fontFamily: fonts.semiBold, + }, + required: { + color: colors.vermelho, + }, + input: { + height: 52, + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 12, + paddingHorizontal: 14, + marginBottom: 16, + fontFamily: fonts.regular, + color: '#1F2937', + }, + passwordWrapper: { + height: 52, + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 12, + paddingHorizontal: 14, + marginBottom: 14, + flexDirection: 'row', + alignItems: 'center', + }, + passwordInput: { + flex: 1, + fontFamily: fonts.regular, + color: '#1F2937', + }, + eyeIcon: { + color: '#9098A3', + }, + forgot: { + color: colors.vermelho, + textAlign: 'center', + fontSize: 13, + textDecorationLine: 'underline', + marginBottom: 20, + fontFamily: fonts.medium, + }, + errorText: { + color: colors.vermelho, + textAlign: 'center', + fontSize: 13, + marginBottom: 12, + fontFamily: fonts.medium, + }, + loginButton: { + backgroundColor: colors.azul, + height: 48, + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + loginButtonText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 18, + }, + loginButtonIcon: { + width: 16, + height: 16, + }, +}); \ No newline at end of file diff --git a/styles/screens/auth/recover.styles.ts b/styles/screens/auth/recover.styles.ts new file mode 100644 index 0000000..b12402e --- /dev/null +++ b/styles/screens/auth/recover.styles.ts @@ -0,0 +1,169 @@ +import { StyleSheet } from 'react-native'; +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + scrollContent: { + flexGrow: 1, + paddingBottom: 40, + }, + hero: { + height: 380, + justifyContent: 'flex-end', + }, + heroImage: { + width: '100%', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.azul_escuro, + opacity: 0.9, + }, + heroContent: { + zIndex: 1, + alignItems: 'center', + paddingHorizontal: 18, + paddingBottom: 100, + }, + logo: { + width: 250, + height: 50, + marginBottom: 18, + }, + title: { + color: colors.branco, + fontSize: 36, + fontFamily: fonts.bold, + marginBottom: 6, + }, + subtitle: { + color: colors.branco, + fontSize: 18, + lineHeight: 29, + textAlign: 'center', + fontFamily: fonts.regular, + }, + formCard: { + marginHorizontal: 24, + marginTop: -62, + backgroundColor: colors.branco, + borderRadius: 18, + paddingHorizontal: 18, + paddingVertical: 20, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 18, + shadowOffset: { width: 0, height: 5 }, + elevation: 4, + }, + label: { + color: colors.azul_text, + fontSize: 15, + marginBottom: 8, + fontFamily: fonts.semiBold, + }, + required: { + color: colors.vermelho, + }, + input: { + height: 52, + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 12, + paddingHorizontal: 14, + marginBottom: 16, + fontFamily: fonts.regular, + color: '#1F2937', + }, + errorText: { + color: colors.vermelho, + textAlign: 'center', + fontSize: 13, + marginBottom: 12, + fontFamily: fonts.medium, + }, + actionButton: { + backgroundColor: colors.azul, + height: 48, + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + actionButtonText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 18, + }, + actionButtonIcon: { + width: 16, + height: 16, + }, + backLink: { + color: colors.vermelho, + textAlign: 'center', + fontSize: 13, + textDecorationLine: 'underline', + marginTop: 16, + fontFamily: fonts.medium, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 9, 25, 0.55)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + modalCard: { + width: '100%', + maxWidth: 360, + backgroundColor: colors.branco, + borderRadius: 16, + paddingVertical: 24, + paddingHorizontal: 20, + alignItems: 'center', + }, + modalIconCircle: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.vermelho, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + modalIcon: { + color: colors.branco, + }, + modalTitle: { + color: colors.azul_text, + fontSize: 30, + fontFamily: fonts.bold, + marginBottom: 8, + textAlign: 'center', + }, + modalMessage: { + color: colors.azul_text, + textAlign: 'center', + fontSize: 16, + lineHeight: 22, + fontFamily: fonts.regular, + marginBottom: 14, + }, + modalCloseButton: { + backgroundColor: colors.azul, + borderRadius: 10, + paddingHorizontal: 22, + paddingVertical: 8, + }, + modalCloseText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 16, + }, +}); \ No newline at end of file diff --git a/styles/screens/reserva/detail.styles.ts b/styles/screens/reserva/detail.styles.ts new file mode 100644 index 0000000..eb5b7e7 --- /dev/null +++ b/styles/screens/reserva/detail.styles.ts @@ -0,0 +1,741 @@ +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 54, + paddingBottom: 10, + gap: 10, + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + logo: { + width: 150, + height: 34, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + paddingHorizontal: 16, + paddingBottom: 120, + gap: 10, + }, + errorText: { + color: colors.vermelho, + fontFamily: fonts.medium, + fontSize: 13, + marginBottom: 8, + }, + summaryCard: { + backgroundColor: colors.branco, + borderRadius: 16, + padding: 12, + }, + rowBetween: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 6, + }, + idTop: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 16, + marginBottom: 2, + }, + idBlock: { + marginTop: 6, + flex: 1, + flexShrink: 1, + marginRight: 8, + }, + statusBadge: { + flexShrink: 1, + maxWidth: '40%', + borderRadius: 12, + paddingVertical: 4, + paddingHorizontal: 6, + backgroundColor: '#E9EDF4', + }, + statusBadgeContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + flexShrink: 1, + }, + statusBadgeText: { + flexShrink: 1, + fontSize: 11, + fontFamily: fonts.medium, + textAlign: 'center', + color: '#4A6592', + }, + statusSuccess: { + backgroundColor: '#D8FFE3', + }, + statusDanger: { + backgroundColor: '#FFE2E0', + }, + statusInfo: { + backgroundColor: '#F3DBFF', + }, + statusPendente: { + backgroundColor: '#FFF6D9', + }, + statusTextSuccess: { + color: '#13AE45', + }, + statusTextDanger: { + color: '#E6463B', + }, + statusTextInfo: { + color: '#B138E6', + }, + statusTextPendente: { + color: '#C99700', + }, + summaryImage: { + width: '100%', + height: 180, + borderRadius: 12, + marginBottom: 8, + backgroundColor: '#E7ECF4', + }, + destination: { + marginTop: 16, + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 24, + marginBottom: 2, + }, + dateText: { + color: '#4A6592', + fontFamily: fonts.regular, + fontSize: 12, + marginBottom: 8, + }, + refsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + }, + smallLabel: { + color: '#4A6592', + fontFamily: fonts.regular, + fontSize: 11, + }, + smallValue: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 14, + }, + priceRow: { + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: '#F3F6FC', + borderRadius: 10, + padding: 8, + }, + priceLeft: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 13, + }, + priceRight: { + color: '#4A6592', + fontFamily: fonts.medium, + fontSize: 13, + }, + sectionHeader: { + backgroundColor: colors.branco, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sectionTitle: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 16, + marginBottom: 10, + }, + sectionBody: { + backgroundColor: colors.branco, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 10, + marginTop: -4, + marginBottom: 2, + gap: 8, + }, + rowLine: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + paddingBottom: 8, + }, + rowLineNoBorder: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + paddingVertical: 8, + }, + rowLabel: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 16, + }, + rowTitle: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 13, + flex: 1, + }, + rowValue: { + color: '#4A6592', + fontFamily: fonts.light, + fontSize: 15, + textAlign: 'right', + flex: 1, + }, + priceBox: { + marginTop: 6, + backgroundColor: colors.azul, + borderRadius: 12, + padding: 10, + }, + priceMain: { + color: colors.branco, + fontSize: 26, + fontFamily: fonts.bold, + marginBottom: 2, + }, + priceSub: { + color: colors.branco, + fontSize: 12, + fontFamily: fonts.medium, + }, + empty: { + marginTop: 10, + color: '#4A6592', + fontFamily: fonts.regular, + fontSize: 12, + }, + extrasList: { + gap: 10, + }, + extrasItem: { + backgroundColor: colors.background_2, + borderRadius: 10, + padding: 10, + }, + extrasItemText: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 13, + }, + + divider: { + height: 1, + backgroundColor: '#EDF1F7', + marginVertical: 10, + }, + blockBorderTop: { + borderTopWidth: 1, + borderTopColor: '#EDF1F7', + paddingTop: 10, + marginTop: 4, + }, + + refsBlock: { + flexDirection: 'row', + gap: 16, + marginTop: 6, + }, + refsCol: { + flex: 1, + }, + + hotelRow: { + flexDirection: 'row', + gap: 12, + paddingVertical: 6, + }, + hotelImage: { + width: 104, + height: 126, + borderRadius: 12, + backgroundColor: '#E7ECF4', + }, + hotelInfo: { + flex: 1, + justifyContent: 'center', + }, + hotelStarsRow: { + flexDirection: 'row', + gap: 2, + marginBottom: 2, + }, + hotelName: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 16, + marginBottom: 4, + }, + hotelMetaLine: { + fontSize: 13, + lineHeight: 17, + marginBottom:4, + }, + hotelMetaLabel: { + color: colors.azul, + fontFamily: fonts.semiBold, + }, + hotelMetaValue: { + color: colors.azul, + fontFamily: fonts.regular, + }, + + vooList: { + gap: 10, + }, + vooCard: { + backgroundColor: colors.background_2, + borderRadius: 14, + padding: 12, + }, + vooRouteRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + vooSideCol: { + flex: 1, + }, + vooSideColRight: { + alignItems: 'flex-end', + }, + vooCode: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 28, + lineHeight: 32, + }, + vooCodeRight: { + textAlign: 'right', + }, + vooMidCol: { + alignItems: 'center', + paddingHorizontal: 8, + gap: 6, + }, + vooDuration: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 11, + }, + vooPathWrap: { + width: 140, + height: 16, + justifyContent: 'center', + alignItems: 'center', + }, + vooPathLine: { + position: 'absolute', + left: 0, + right: 0, + height: 1, + backgroundColor: colors.cinza, + }, + vooPathIcon: { + backgroundColor: '#EDEDED', + paddingHorizontal: 5, + }, + vooTime: { + color: colors.azul, + fontFamily: fonts.light, + fontSize: 15, + marginTop: 2, + }, + vooTimeRight: { + textAlign: 'right', + }, + vooAirport: { + color: colors.azul, + fontFamily: fonts.light, + fontSize: 11, + marginTop: 2, + flexShrink: 1, + }, + vooAirportRight: { + textAlign: 'right', + }, + vooInfoBox: { + flexDirection: 'row', + borderWidth: 1, + borderColor: colors.cinza, + borderRadius: 10, + paddingTop: 10, + paddingBottom: 10, + paddingHorizontal: 20, + marginTop: 12, + marginBottom: 12, + gap: 8, + }, + vooInfoColLeft: { + width: '50%', + flex: 1, + gap: 6, + alignItems: 'center', + justifyContent: 'space-around', + }, + vooInfoColRight: { + width: '50%', + flex: 1, + gap: 6, + alignItems: 'center', + justifyContent: 'space-around', + }, + vooInfoDate: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 14, + }, + vooLegRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + vooLegLabel: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 14, + letterSpacing: 0.3, + }, + vooMetaLine: { + fontSize: 14, + lineHeight: 17, + }, + vooMetaLabel: { + color: colors.azul, + fontFamily: fonts.semiBold, + }, + vooMetaValue: { + color: colors.azul, + fontFamily: fonts.regular, + }, + vooEscalasBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + alignSelf: 'flex-start', + backgroundColor: '#E7ECF4', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + marginBottom: 10, + }, + vooEscalasBadgeText: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 14, + }, + vooEscalaDivider: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginVertical: 10, + }, + vooEscalaLine: { + flex: 1, + height: 1, + backgroundColor: colors.cinza, + }, + vooEscalaChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + backgroundColor: '#F0F3FA', + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + vooEscalaChipText: { + color: '#4A6592', + fontFamily: fonts.medium, + fontSize: 11, + }, + mapaBtn: { + backgroundColor: colors.azul, + borderRadius: 10, + paddingVertical: 11, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: 8, + }, + mapaBtnText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 12, + }, + mapaBtnIcon: { + width: 12, + height: 12, + }, + passageiroHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + passageiroContainer: { + marginVertical: 10, + }, + passageiroSubtitle: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 14, + }, + passageiroName: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 14, + }, + passageiroBodyWrap: { + overflow: 'hidden', + }, + passageiroBodyMeasure: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + }, + passageiroBody: { + marginTop: 10, + gap: 10, + }, + twoCol: { + flexDirection: 'row', + gap: 10, + }, + twoColItem: { + flex: 1, + }, + fieldLabel: { + color: '#4A6592', + fontFamily: fonts.regular, + fontSize: 14, + marginBottom: 4, + }, + fieldBox: { + backgroundColor: colors.background_2, + borderWidth: 1, + borderColor: colors.cinza, + borderRadius: 10, + paddingVertical: 9, + paddingHorizontal: 10, + }, + fieldBoxText: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 13, + }, + refsField: { + flex: 1, + }, + + extrasText: { + color: colors.azul, + fontFamily: fonts.regular, + fontSize: 13, + lineHeight: 19, + }, + + tableHeader: { + flexDirection: 'row', + paddingBottom: 6, + }, + tableRow: { + flexDirection: 'row', + paddingVertical: 8, + }, + tableRowLast: { + borderBottomWidth: 0, + }, + tableCellLeft: { + flex: 1.2, + }, + tableCellMid: { + flex: 1, + alignItems: 'flex-start', + }, + tableCellRight: { + flex: 1.2, + alignItems: 'flex-end', + }, + tableHeaderText: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 14, + }, + tableValueText: { + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 13, + }, + valorSection: { + backgroundColor: colors.branco, + borderRadius: 16, + padding: 12, + gap: 10, + }, + valorInnerCard: { + backgroundColor: colors.azul, + borderRadius: 14, + padding: 16, + gap: 6, + }, + valorAmount: { + color: colors.branco, + fontFamily: fonts.regular, + fontSize: 36, + }, + valorAmountLabel: { + color: colors.branco, + fontFamily: fonts.regular, + fontSize: 16, + opacity: 0.9, + marginBottom: 8, + marginTop: -10, + }, + valorPagoLine: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 14, + marginTop: 8, + }, + valorProgressTrack: { + height: 20, + borderRadius: 10, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + marginBottom: 14, + overflow: 'hidden', + }, + valorProgressFill: { + height: '100%', + backgroundColor: colors.branco, + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + paddingRight: 4, + }, + valorProgressDot: { + width: 14, + height: 14, + borderRadius: 8, + backgroundColor: colors.vermelho, + }, + valorFaltaRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + valorFaltaRowPago: { + justifyContent: 'flex-start', + }, + valorPagoStatus: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + valorFaltaLabel: { + color: colors.branco, + fontFamily: fonts.regular, + fontSize: 14, + }, + valorFaltaBadge: { + backgroundColor: 'rgba(255, 255, 255, 0.15)', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 6, + }, + valorFaltaBadgeText: { + color: colors.branco, + fontFamily: fonts.bold, + fontSize: 14, + }, + + documentoRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + }, + documentoRowLast: { + borderBottomWidth: 0, + }, + documentoBadge: { + backgroundColor: '#FFE2E0', + borderRadius: 6, + paddingVertical: 2, + paddingHorizontal: 6, + }, + documentoBadgeText: { + color: '#E6463B', + fontFamily: fonts.bold, + fontSize: 10, + }, + documentoName: { + flex: 1, + color: colors.azul, + fontFamily: fonts.medium, + fontSize: 13, + }, + offlineBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: '#4A6592', + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: 12, + marginBottom: 12, + }, + offlineBannerText: { + color: '#ffffff', + fontFamily: fonts.medium, + fontSize: 12, + flex: 1, + }, + syncLabel: { + textAlign: 'center', + color: '#9AA8BE', + fontFamily: fonts.regular, + fontSize: 11, + marginTop: 8, + marginBottom: 16, + }, +}); diff --git a/styles/screens/root/index.styles.ts b/styles/screens/root/index.styles.ts new file mode 100644 index 0000000..bc521ec --- /dev/null +++ b/styles/screens/root/index.styles.ts @@ -0,0 +1,9 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); \ No newline at end of file diff --git a/styles/screens/tabs/contactos.styles.ts b/styles/screens/tabs/contactos.styles.ts new file mode 100644 index 0000000..c9772af --- /dev/null +++ b/styles/screens/tabs/contactos.styles.ts @@ -0,0 +1,221 @@ +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; +import { StyleSheet, Platform } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + centered: { + flex: 1, + backgroundColor: colors.background_1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + content: { + paddingHorizontal: 16, + paddingTop: 18, + paddingBottom: 120, + }, + pageTitle: { + fontFamily: fonts.bold, + fontSize: 22, + color: colors.azul, + marginBottom: 18, + }, + errorText: { + fontFamily: fonts.medium, + fontSize: 14, + color: colors.vermelho, + textAlign: 'center', + }, + + // ── Ações rápidas ────────────────────────────────────────────────────────── + actionsRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 14, + }, + actionBtn: { + flex: 1, + backgroundColor: colors.azul, + borderRadius: 14, + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 6, + gap: 4, + }, + actionIconWrap: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.background_1, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 2, + }, + actionIcon: { + width: 20, + height: 20, + tintColor: colors.azul, + resizeMode: 'contain', + }, + actionLabel: { + fontFamily: fonts.regular, + fontSize: 14, + color: colors.branco, + textAlign: 'center', + }, + actionSub: { + fontFamily: fonts.regular, + fontSize: 12, + color: colors.branco, + textAlign: 'center', + }, + + // ── Card genérico ────────────────────────────────────────────────────────── + card: { + backgroundColor: colors.branco, + borderRadius: 14, + padding: 16, + marginBottom: 14, + shadowColor: '#000', + shadowOpacity: 0.04, + shadowRadius: 6, + shadowOffset: { width: 0, height: 2 }, + elevation: 2, + }, + cardTitle: { + fontFamily: fonts.bold, + fontSize: 15, + color: colors.azul, + marginBottom: 12, + }, + + // ── Localização ─────────────────────────────────────────────────────────── + mapImageWrap: { + width: '100%', + height: 160, + borderRadius: 10, + overflow: 'hidden', + marginBottom: 12, + backgroundColor: colors.background_2, + }, + mapImage: { + width: '100%', + height: '100%', + }, + mapOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 6, + backgroundColor: colors.background_2, + }, + mapErrorText: { + fontFamily: fonts.medium, + fontSize: 12, + color: '#9AA8BE', + }, + addressRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + marginBottom: 14, + }, + addressText: { + flex: 1, + fontFamily: fonts.regular, + fontSize: 13, + color: colors.azul, + lineHeight: 20, + }, + copyIcon: { + marginTop: 2, + paddingHorizontal: 4, + }, + direcoeBtn: { + backgroundColor: colors.azul, + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 13, + }, + direcoeBtnText: { + fontFamily: fonts.regular, + fontSize: 16, + color: colors.branco, + }, + saveButtonIcon: { + width: 14, + height: 14, + }, + + // ── Horário ─────────────────────────────────────────────────────────────── + horarioRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + }, + horarioRowBorder: { + borderBottomWidth: 1, + borderBottomColor: colors.background_1, + }, + horarioDia: { + fontFamily: fonts.semiBold, + fontSize: 13, + color: colors.azul, + }, + horarioHoras: { + fontFamily: fonts.regular, + fontSize: 13, + color: colors.azul, + }, + horarioFechado: { + color: colors.vermelho, + fontFamily: fonts.medium, + }, + + // ── Emergência ──────────────────────────────────────────────────────────── + emergencyBtn: { + backgroundColor: '#25D366', + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 13, + paddingHorizontal: 16, + gap: 10, + }, + emergencyIcon: { + width: 22, + height: 22, + resizeMode: 'contain', + tintColor: colors.branco, + }, + emergencyText: { + flex: 1, + fontFamily: fonts.semiBold, + fontSize: 14, + color: colors.branco, + }, + + // ── Redes Sociais ───────────────────────────────────────────────────────── + socialsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + socialBtn: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.background_1, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/styles/screens/tabs/home.styles.ts b/styles/screens/tabs/home.styles.ts new file mode 100644 index 0000000..f56007a --- /dev/null +++ b/styles/screens/tabs/home.styles.ts @@ -0,0 +1,226 @@ +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + centered: { + flex: 1, + backgroundColor: colors.background_1, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + paddingHorizontal: 16, + paddingTop: 14, + paddingBottom: 120, + }, + logo: { + width: 165, + height: 36, + alignSelf: 'center', + marginBottom: 14, + }, + statsRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 18, + }, + statCard: { + flex: 1, + backgroundColor: colors.branco, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + paddingHorizontal: 6, + minHeight: 82, + }, + statCardPrimary: { + backgroundColor: colors.azul, + }, + statIconEmViagemWrap: { + width: 38, + height: 38, + backgroundColor: colors.branco, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 5, + }, + statIcon: { + width: 38, + height: 38, + marginBottom: 5, + }, + statLabel: { + fontSize: 14, + color: colors.azul, + fontFamily: fonts.regular, + marginBottom: 2, + textAlign: 'center', + }, + statValue: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.medium, + lineHeight: 32, + }, + statLabelPrimary: { + fontSize: 14, + color: colors.branco, + fontFamily: fonts.regular, + marginBottom: 2, + textAlign: 'center', + }, + statValuePrimary: { + fontSize: 24, + color: colors.branco, + fontFamily: fonts.medium, + lineHeight: 32, + }, + sectionTitle: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.bold, + marginBottom: 12, + }, + card: { + backgroundColor: colors.branco, + borderRadius: 16, + padding: 10, + flexDirection: 'row', + gap: 10, + marginBottom: 12, + alignItems: 'stretch', + }, + cardImage: { + width: 100, + height: 120, + borderRadius: 14, + backgroundColor: '#E7ECF4', + }, + cardContent: { + flex: 1, + justifyContent: 'space-between', + height: 100, + }, + arrowWrap: { + justifyContent: 'flex-end', + alignItems: 'flex-end', + paddingLeft: 2, + }, + cardTopRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + }, + refText: { + flex: 1, + flexShrink: 1, + fontSize: 12, + color: '#4A6592', + fontFamily: fonts.regular, + marginRight: 4, + }, + destination: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.bold, + lineHeight: 35, + marginTop: 2, + marginBottom: 2, + }, + meta: { + fontSize: 11, + color: '#4A6592', + fontFamily: fonts.regular, + }, + dateRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + marginTop: 2, + }, + dateIcon: { + width: 12, + height: 12, + tintColor: '#4A6592', + }, + statusBadge: { + flexShrink: 1, + maxWidth: '60%', + borderRadius: 12, + paddingVertical: 4, + paddingHorizontal: 6, + backgroundColor: '#E9EDF4', + }, + statusBadgeContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + flexShrink: 1, + }, + statusText: { + flexShrink: 1, + fontSize: 12, + fontFamily: fonts.medium, + textAlign: 'center', + }, + statusSuccess: { + backgroundColor: '#D8FFE3', // verde + }, + statusDanger: { + backgroundColor: '#FFE2E0', // vermelho + }, + statusInfo: { + backgroundColor: '#F3DBFF', // roxo + }, + statusPendente: { + backgroundColor: '#FFF6D9', + }, + statusTextSuccess: { + color: '#13AE45', + }, + statusTextDanger: { + color: '#E6463B', + }, + statusTextInfo: { + color: '#B138E6', + }, + statusTextPendente: { + color: '#C99700', + }, + offlineBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: '#4A6592', + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: 12, + marginBottom: 12, + }, + offlineBannerText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 12, + flex: 1, + }, + errorText: { + fontSize: 14, + color: colors.vermelho, + fontFamily: fonts.medium, + marginBottom: 10, + }, + emptyText: { + fontSize: 14, + color: '#4A6592', + fontFamily: fonts.medium, + }, +}); \ No newline at end of file diff --git a/styles/screens/tabs/index.styles.ts b/styles/screens/tabs/index.styles.ts new file mode 100644 index 0000000..bc521ec --- /dev/null +++ b/styles/screens/tabs/index.styles.ts @@ -0,0 +1,9 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); \ No newline at end of file diff --git a/styles/screens/tabs/perfil.styles.ts b/styles/screens/tabs/perfil.styles.ts new file mode 100644 index 0000000..9661e01 --- /dev/null +++ b/styles/screens/tabs/perfil.styles.ts @@ -0,0 +1,231 @@ +import { colors } from '@/assets/styles/colors'; +import { fonts } from '@/assets/styles/fonts'; +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background_1, + }, + content: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 200, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 18, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#EEF0F5', + alignItems: 'center', + justifyContent: 'center', + }, + logo: { + width: 160, + height: 34, + }, + title: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.bold, + marginBottom: 12, + }, + userCard: { + backgroundColor: colors.branco, + borderRadius: 16, + paddingHorizontal: 18, + paddingVertical: 16, + marginBottom: 14, + }, + userName: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.bold, + }, + userEmail: { + fontSize: 12, + color: '#4A6592', + fontFamily: fonts.regular, + }, + menuCard: { + backgroundColor: colors.branco, + borderRadius: 16, + overflow: 'hidden', + }, + menuRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 16, + }, + menuLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + menuText: { + fontSize: 18, + color: colors.azul, + fontFamily: fonts.regular, + + }, + divider: { + height: 1, + backgroundColor: '#EEF0F5', + marginHorizontal: 16, + }, + editBodyWrap: { + overflow: 'hidden', + }, + editBodyMeasure: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + }, + accordionContent: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 10, + }, + rowInputs: { + flexDirection: 'row', + gap: 8, + }, + inputBlock: { + flex: 1, + }, + inputBlockFull: {}, + inputLabel: { + color: colors.azul, + fontFamily: fonts.bold, + fontSize: 16, + marginBottom: 6, + }, + required: { + color: colors.vermelho, + }, + input: { + height: 48, + borderWidth: 1, + borderColor: '#C7CEDB', + borderRadius: 12, + paddingHorizontal: 12, + color: '#1F2937', + fontFamily: fonts.regular, + fontSize: 16, + backgroundColor: '#FAFBFD', + }, + saveButton: { + marginTop: 8, + height: 48, + borderRadius: 10, + backgroundColor: colors.azul, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 6, + }, + saveButtonText: { + color: colors.branco, + fontFamily: fonts.medium, + fontSize: 18, + }, + saveButtonIcon: { + width: 14, + height: 14, + }, + deleteProfileButtonIcon: { + width: 22, + height: 18, + }, + arrowUpButtonIcon: { + width: 14, + height: 14, + color: colors.azul + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(30, 41, 59, 0.45)', + justifyContent: 'center', + paddingHorizontal: 16, + }, + modalCard: { + backgroundColor: colors.branco, + borderRadius: 16, + paddingHorizontal: 18, + paddingTop: 22, + paddingBottom: 16, + alignItems: 'center', + }, + modalIconWrap: { + width: 42, + height: 42, + borderRadius: 21, + backgroundColor: colors.vermelho, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + }, + modalTitle: { + fontSize: 24, + color: colors.azul, + fontFamily: fonts.bold, + marginBottom: 8, + textAlign: 'center', + }, + modalSubtitle: { + fontSize: 16, + color: colors.azul, + fontFamily: fonts.regular, + textAlign: 'center', + marginBottom: 16, + lineHeight: 22, + }, + modalButtons: { + width: '100%', + flexDirection: 'row', + gap: 8, + }, + modalButton: { + flex: 1, + height: 44, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 6, + }, + modalPrimaryButton: { + backgroundColor: colors.azul, + }, + modalSecondaryButton: { + backgroundColor: colors.branco, + borderWidth: 1, + borderColor: colors.azul, + }, + modalPrimaryText: { + color: colors.branco, + fontSize: 18, + fontFamily: fonts.medium, + }, + modalSecondaryText: { + color: colors.azul, + fontSize: 18, + fontFamily: fonts.medium, + }, + modalButtonIcon: { + transform: [{ rotate: '45deg' }], + }, + perfilIcon: { + width: 16, + height: 19, + }, +}); \ No newline at end of file diff --git a/styles/screens/tabs/reservas.styles.ts b/styles/screens/tabs/reservas.styles.ts new file mode 100644 index 0000000..bc521ec --- /dev/null +++ b/styles/screens/tabs/reservas.styles.ts @@ -0,0 +1,9 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); \ No newline at end of file diff --git a/svg.d.ts b/svg.d.ts new file mode 100644 index 0000000..fed8d16 --- /dev/null +++ b/svg.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import React from 'react'; + import { SvgProps } from 'react-native-svg'; + const content: React.FC; + export default content; +}