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;
+}