First commit of the new app

This commit is contained in:
2026-05-26 09:18:37 +01:00
parent 295d1bda21
commit b427fb0f85
110 changed files with 6483 additions and 833 deletions

5
app/(auth)/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

110
app/(auth)/login/index.tsx Normal file
View File

@@ -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<string | null>(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 (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}>
<StatusBar barStyle="light-content" />
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}>
<ImageBackground
source={require('@/assets/images/banner-login.png')}
style={styles.hero}
imageStyle={styles.heroImage}>
<View style={styles.overlay} />
<View style={styles.heroContent}>
<Image source={require('@/assets/icons/logotipo-branco.png')} style={styles.logo} resizeMode="contain" />
<Text style={styles.title}>Login</Text>
<Text style={styles.subtitle}>Acede às tuas reservas, documentos e informaçoes de viagem.</Text>
</View>
</ImageBackground>
<View style={styles.formCard}>
<Text style={styles.label}>
Email<Text style={styles.required}>*</Text>
</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="hello@domain.pt"
placeholderTextColor="#9AA0A6"
autoCapitalize="none"
keyboardType="email-address"
style={styles.input}
/>
<Text style={styles.label}>
Palavra-passe<Text style={styles.required}>*</Text>
</Text>
<View style={styles.passwordWrapper}>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Palavra-passe"
placeholderTextColor="#9AA0A6"
secureTextEntry={!showPassword}
style={styles.passwordInput}
/>
<Pressable onPress={() => setShowPassword((prev) => !prev)} hitSlop={8}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} style={styles.eyeIcon} />
</Pressable>
</View>
<Pressable onPress={() => router.push('/recover' as Href)}>
<Text style={styles.forgot}>Esqueci-me da palavra-passe</Text>
</Pressable>
{!!(localError || error) && <Text style={styles.errorText}>{localError || error}</Text>}
<Pressable style={styles.loginButton} onPress={handleLogin} disabled={isLoading}>
<Text style={styles.loginButtonText}>Entrar</Text>
<Image source={require('@/assets/icons/seta-up.png')} style={styles.loginButtonIcon} />
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -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<string | null>(null);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const handleRecover = () => {
if (!email.trim()) {
setLocalError('Indica o teu email para continuar.');
return;
}
setLocalError(null);
setShowSuccessModal(true);
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}>
<StatusBar barStyle="light-content" />
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}>
<ImageBackground
source={require('@/assets/images/banner-login.png')}
style={styles.hero}
imageStyle={styles.heroImage}>
<View style={styles.overlay} />
<View style={styles.heroContent}>
<Image source={require('@/assets/icons/logotipo-branco.png')} style={styles.logo} resizeMode="contain" />
<Text style={styles.title}>Repor palavra-passe</Text>
<Text style={styles.subtitle}>Indica o teu email para receberes o link de recuperacao.</Text>
</View>
</ImageBackground>
<View style={styles.formCard}>
<Text style={styles.label}>
Email<Text style={styles.required}>*</Text>
</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="hello@domain.pt"
placeholderTextColor="#9AA0A6"
autoCapitalize="none"
keyboardType="email-address"
style={styles.input}
/>
{!!localError && <Text style={styles.errorText}>{localError}</Text>}
<Pressable style={styles.actionButton} onPress={handleRecover}>
<Text style={styles.actionButtonText}>Recuperar Palavra-passe</Text>
<Image source={require('@/assets/icons/seta-up.png')} style={styles.actionButtonIcon} />
</Pressable>
<Pressable onPress={() => router.replace('/login' as Href)}>
<Text style={styles.backLink}>Voltar ao Login</Text>
</Pressable>
</View>
</ScrollView>
<Modal visible={showSuccessModal} animationType="fade" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<View style={styles.modalIconCircle}>
<Ionicons name="mail-open-outline" size={22} style={styles.modalIcon} />
</View>
<Text style={styles.modalTitle}>Verifica o teu email</Text>
<Text style={styles.modalMessage}>Enviamos-te as instrucoes para recuperares a palavra-passe.</Text>
<Pressable
style={styles.modalCloseButton}
onPress={() => {
setShowSuccessModal(false);
router.replace('/login' as Href);
}}>
<Text style={styles.modalCloseText}>Ok</Text>
</Pressable>
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}

View File

@@ -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 (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
headerShown: true,
headerTitleAlign: 'center',
headerShadowVisible: false,
headerStyle: {
backgroundColor: colors.background_1,
},
headerTitle: () => (
<Image source={require('@/assets/icons/logotipo-azul.png')} style={styles.logo} resizeMode="contain" />
),
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,
}}>
<Tabs.Screen
name="index"
name="perfil/index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
title: 'Perfil',
tabBarIcon: ({ focused }) => (
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
<Image
source={
focused
? require('@/assets/icons/perfil-selecionado.png')
: require('@/assets/icons/perfil.png')
}
style={focused ? styles.iconActive : styles.icon}
resizeMode="contain"
/>
</View>
),
}}
/>
<Tabs.Screen
name="explore"
name="home/index"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
title: 'Inicio',
tabBarIcon: ({ focused }) => (
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
<Image
source={
focused
? require('@/assets/icons/home-selecionado.png')
: require('@/assets/icons/home.png')
}
style={focused ? styles.iconActive : styles.icon}
resizeMode="contain"
/>
</View>
),
}}
/>
<Tabs.Screen
name="contactos/index"
options={{
title: 'Contactos',
tabBarIcon: ({ focused }) => (
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
<Image
source={
focused
? require('@/assets/icons/contactos-fill.png')
: require('@/assets/icons/contactos.png')
}
style={focused ? styles.iconActive : styles.icon}
resizeMode="contain"
/>
</View>
),
}}
/>
</Tabs>
);
}
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,
},
});

View File

@@ -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<string, string> = {
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<ContactData | null>(null);
const [socials, setSocials] = useState<SocialMedia[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<View style={styles.centered}>
<LoadingSpinner size="large" />
</View>
);
}
if (error && !contactData) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
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 (
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<Text style={styles.pageTitle}>Contactos Agência</Text>
{/* ── Ações rápidas ── */}
<View style={styles.actionsRow}>
{!!contactData?.mobilePhone && (
<Pressable
style={styles.actionBtn}
onPress={() => openPhone(contactData.telephone)}>
<View style={styles.actionIconWrap}>
<Image source={require('@/assets/icons/telefone.png')} style={styles.actionIcon} />
</View>
<Text style={styles.actionLabel}>Ligar Agora</Text>
<Text style={styles.actionSub} numberOfLines={1}>
{contactData.telephone}
</Text>
</Pressable>
)}
{!!contactData?.email && (
<Pressable
style={styles.actionBtn}
onPress={() => openEmail(contactData.email)}>
<View style={styles.actionIconWrap}>
<Image source={require('@/assets/icons/email.png')} style={styles.actionIcon} />
</View>
<Text style={styles.actionLabel}>Enviar Email</Text>
<Text style={styles.actionSub} numberOfLines={1}>
{contactData.email}
</Text>
</Pressable>
)}
{!!contactData?.mobilePhone && (
<Pressable
style={styles.actionBtn}
onPress={() => openWhatsApp(contactData.mobilePhone!)}>
<View style={styles.actionIconWrap}>
<Image source={require('@/assets/icons/whatsapp.png')} style={styles.actionIcon} />
</View>
<Text style={styles.actionLabel}>Falar no Chat</Text>
<Text style={styles.actionSub} numberOfLines={1}>
{extractPhone(contactData.mobilePhone)}
</Text>
</Pressable>
)}
</View>
{/* ── Localização ── */}
{!!contactData?.address && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Localização</Text>
{!!mapUrl && (
<Pressable
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}
style={styles.mapImageWrap}>
<Image
source={{ uri: mapUrl }}
style={styles.mapImage}
resizeMode="cover"
onLoadStart={() => { setMapLoading(true); setMapError(false); }}
onLoad={() => setMapLoading(false)}
onError={() => { setMapLoading(false); setMapError(true); }}
/>
{mapLoading && !mapError && (
<View style={styles.mapOverlay}>
<LoadingSpinner size="small" />
</View>
)}
{mapError && (
<View style={styles.mapOverlay}>
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
</View>
)}
</Pressable>
)}
<View style={styles.addressRow}>
<FontAwesome name="map-marker" size={16} color={colors.vermelho} />
<Text style={styles.addressText}>{contactData.address}</Text>
<Pressable onPress={() => copyToClipboard(contactData.address, 'Morada')}>
<FontAwesome name="copy" size={14} color={colors.azul} style={styles.copyIcon} />
</Pressable>
</View>
<Pressable
style={styles.direcoeBtn}
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}>
<Text style={styles.direcoeBtnText}>Obter Direções</Text>
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
</Pressable>
</View>
)}
{/* ── Horário de Funcionamento ── */}
{horarios.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Horário de Funcionamento</Text>
{horarios.map((item, idx) => (
<View
key={idx}
style={[styles.horarioRow, idx < horarios.length - 1 && styles.horarioRowBorder]}>
<Text style={styles.horarioDia}>{item.dia}</Text>
<Text style={[
styles.horarioHoras,
item.horas.toLowerCase() === 'fechado' && styles.horarioFechado,
]}>
{item.horas || '—'}
</Text>
</View>
))}
</View>
)}
{/* ── Redes Sociais ── */}
{socials.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Redes Sociais</Text>
<View style={styles.socialsRow}>
{socials.map((social, idx) => (
<Pressable
key={idx}
style={styles.socialBtn}
onPress={() => Linking.openURL(social.value).catch(() => null)}>
<FontAwesome
name={(SOCIAL_ICONS[social.key.toLowerCase()] ?? 'globe') as any}
size={20}
color={colors.azul}
/>
</Pressable>
))}
</View>
</View>
)}
</ScrollView>
</View>
);
}

View File

@@ -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 (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}>
Explore
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require('@/assets/images/react-logo.png')}
style={{ width: 100, height: 100, alignSelf: 'center' }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{' '}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

333
app/(tabs)/home/index.tsx Normal file
View File

@@ -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<Reserva[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<View style={styles.centered}>
<LoadingSpinner size="large" />
</View>
);
}
console.log(reservas);
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.statsRow}>
<View style={[styles.statCard, styles.statCardPrimary]}>
{stats.emViagem ? (
<View style={styles.statIconEmViagemWrap}>
<FontAwesome name="ship" size={20} color={colors.azul} />
</View>
) : (
<Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} />
)}
<Text style={styles.statLabelPrimary}>
{stats.emViagem ? 'Em viagem' : 'Proxima Viagem'}
</Text>
<Text style={styles.statValuePrimary} numberOfLines={stats.emViagem ? 2 : 1}>
{stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`}
</Text>
</View>
<View style={styles.statCard}>
<Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} />
<Text style={styles.statLabel}>Total Reservas</Text>
<Text style={styles.statValue}>{stats.total}</Text>
</View>
<View style={styles.statCard}>
<Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} />
<Text style={styles.statLabel}>Confirmadas</Text>
<Text style={styles.statValue}>{stats.confirmadas}</Text>
</View>
</View>
{isFromCache && (
<View style={styles.offlineBanner}>
<FontAwesome name="wifi" size={13} color={colors.branco} />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View>
)}
<Text style={styles.sectionTitle}>As minhas Reservas</Text>
{!!error && <Text style={styles.errorText}>{error}</Text>}
{!error && reservas.length === 0 && (
<Text style={styles.emptyText}>Sem reservas para apresentar.</Text>
)}
{!error &&
reservas.map((reserva, index) => {
const statusType = getStatusType(reserva.statusCode);
const statusConfig = getStatusConfig(reserva.statusCode);
const statusColor = getStatusColor(statusType);
return (
<Pressable
key={`${reserva.referenciaViagem}-${index}`}
style={styles.card}
onPress={() =>
router.push(`/reserva/${encodeURIComponent(reserva.referenciaViagem || '')}` as Href)
}>
<Image
source={
reserva.imagemCidade
? { uri: reserva.imagemCidade }
: require('@/assets/icons/logo.png')
}
style={styles.cardImage}
/>
<View style={styles.cardContent}>
<View style={styles.cardTopRow}>
<Text style={styles.refText}>{reserva.referenciaViagem || '---'}</Text>
<View
style={[
styles.statusBadge,
statusType === 'verde' && styles.statusSuccess,
statusType === 'vermelho' && styles.statusDanger,
statusType === 'roxo' && styles.statusInfo,
statusType === 'amarelo' && styles.statusPendente,
]}>
<View style={styles.statusBadgeContent}>
<FontAwesome name={statusConfig.icon} size={12} color={statusColor} />
<Text
style={[
styles.statusText,
statusType === 'verde' && styles.statusTextSuccess,
statusType === 'vermelho' && styles.statusTextDanger,
statusType === 'roxo' && styles.statusTextInfo,
statusType === 'amarelo' && styles.statusTextPendente,
]}>
{statusConfig.label}
</Text>
</View>
</View>
</View>
<Text style={styles.destination}>{reserva.destino}</Text>
<Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text>
<View style={styles.dateRow}>
<FontAwesome name="calendar" size={12} color="#4A6592" />
<Text style={styles.meta}>{reserva.startDate}</Text>
</View>
</View>
<View style={styles.arrowWrap}>
<FontAwesome name="angle-right" size={20} color="#EB2415" />
</View>
</Pressable>
);
})}
</ScrollView>
</View>
);
}

View File

@@ -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 (
<ParallaxScrollView
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={
<Image
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
web: 'F12',
})}
</ThemedText>{' '}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href="/modal">
<Link.Trigger>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={() => alert('Share pressed')}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => alert('Delete pressed')}
/>
</Link.Menu>
</Link.Menu>
</Link>
<ThemedText>
{`Tap the Explore tab to learn more about what's included in this starter app.`}
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
{`When you're ready, run `}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
);
}
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',
},
});

370
app/(tabs)/perfil/index.tsx Normal file
View File

@@ -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<UserInfoResponse | null>(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 | 'logout' | 'delete'>(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 (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}>
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<Text style={styles.title}>O meu Perfil</Text>
<View style={styles.userCard}>
<Text style={styles.userName}>{userInfo?.user?.nome + ' ' + userInfo?.user?.apelido || 'Utilizador'}</Text>
<Text style={styles.userEmail}>{userInfo?.user?.email || 'Utilizador'}</Text>
</View>
<View style={styles.menuCard}>
<Pressable style={styles.menuRow} onPress={() => setIsEditingOpen((prev) => !prev)}>
<View style={styles.menuLeft}>
<UserIcon width={24} height={24} fill={colors.azul} />
<Text style={styles.menuText}>Editar Perfil</Text>
</View>
<Animated.View style={chevronAnimatedStyle}>
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
</Animated.View>
</Pressable>
<Animated.View
style={[styles.editBodyWrap, editBodyAnimatedStyle]}
pointerEvents={isEditingOpen ? 'auto' : 'none'}>
<View
style={styles.editBodyMeasure}
onLayout={(e) => {
const h = Math.ceil(e.nativeEvent.layout.height);
if (h > 0) setMeasuredHeight(h);
}}>
<View style={styles.accordionContent}>
<View style={styles.rowInputs}>
<View style={styles.inputBlock}>
<Text style={styles.inputLabel}>Nome<Text style={styles.required}></Text></Text>
<TextInput
value={userInfo?.user?.nome || ''}
onChangeText={(text) =>
setUserInfo((prev) =>
prev?.user ? { ...prev, user: { ...prev.user, nome: text } } : prev,
)
}
style={styles.input}
placeholder="Nome"
/>
</View>
<View style={styles.inputBlock}>
<Text style={styles.inputLabel}>Apelido<Text style={styles.required}></Text></Text>
<TextInput
value={userInfo?.user?.apelido || ''}
onChangeText={(text) =>
setUserInfo((prev) =>
prev?.user ? { ...prev, user: { ...prev.user, apelido: text } } : prev,
)
}
style={styles.input}
placeholder="Apelido"
/>
</View>
</View>
<View style={styles.inputBlockFull}>
<Text style={styles.inputLabel}>Nova Palavra-passe<Text style={styles.required}></Text></Text>
<TextInput
value={newPassword}
onChangeText={setNewPassword}
style={styles.input}
placeholder="Palavra-passe"
secureTextEntry
/>
</View>
<View style={styles.inputBlockFull}>
<Text style={styles.inputLabel}>Confirmar Nova Palavra-passe<Text style={styles.required}></Text></Text>
<TextInput
value={confirmPassword}
onChangeText={setConfirmPassword}
style={styles.input}
placeholder="Palavra-passe"
secureTextEntry
/>
</View>
<Pressable style={styles.saveButton} onPress={handleSave} disabled={isSubmitting || isLoading}>
{isSubmitting ? (
<LoadingSpinner size="small" />
) : (
<>
<Text style={styles.saveButtonText}>Guardar Alteracoes</Text>
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
</>
)}
</Pressable>
</View>
</View>
</Animated.View>
<View style={styles.divider} />
<Pressable style={styles.menuRow} onPress={handleLogout}>
<View style={styles.menuLeft}>
<LogoutIcon width={24} height={24} fill={colors.azul} />
<Text style={styles.menuText}>Log out</Text>
</View>
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
</Pressable>
<View style={styles.divider} />
<Pressable style={styles.menuRow} onPress={handleDeleteAccount} disabled={isDeletingAccount}>
<View style={styles.menuLeft}>
<UserDeleteIcon width={24} height={24} fill={colors.azul} />
<Text style={styles.menuText}>{isDeletingAccount ? 'A eliminar...' : 'Eliminar Conta'}</Text>
</View>
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
</Pressable>
</View>
</ScrollView>
{confirmModal && (
<Modal visible transparent animationType="fade" onRequestClose={() => setConfirmModal(null)}>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<View style={styles.modalIconWrap}>
{confirmModal === 'delete' ? (
<UserDeleteIcon width={24} height={24} fill={colors.branco} />
) : (
<LogoutIcon width={24} height={24} fill={colors.branco} />
)}
</View>
<Text style={styles.modalTitle}>
{confirmModal === 'delete' ? 'Eliminar Conta' : 'Fazer Log out'}
</Text>
<Text style={styles.modalSubtitle}>
{confirmModal === 'delete'
? 'Tens a certeza que queres eliminar a tua conta?'
: 'Tens a certeza que queres sair da tua conta?'}
</Text>
<View style={styles.modalButtons}>
<Pressable
style={[styles.modalButton, styles.modalPrimaryButton]}
onPress={confirmModalAction}
disabled={isDeletingAccount}>
<Text style={styles.modalPrimaryText}>
{confirmModal === 'delete' ? (isDeletingAccount ? 'A eliminar...' : 'Eliminar') : 'Sair'}
</Text>
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
</Pressable>
<Pressable
style={[styles.modalButton, styles.modalSecondaryButton]}
onPress={() => setConfirmModal(null)}>
<Text style={styles.modalSecondaryText}>Cancelar</Text>
<Image source={require('@/assets/icons/seta-up-fill.png')} style={styles.arrowUpButtonIcon} />
</Pressable>
</View>
</View>
</View>
</Modal>
)}
</KeyboardAvoidingView>
);
}

View File

@@ -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 (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<AuthProvider>
<View style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} />
<EmergencyButton />
</View>
</AuthProvider>
</GestureHandlerRootView>
);
}

29
app/index.tsx Normal file
View File

@@ -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 (
<View style={styles.container}>
<LoadingSpinner size="large" />
</View>
);
}

View File

@@ -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 (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

View File

@@ -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<string | null>(null);
const [reservaData, setReservaData] = useState<ReservaData | null>(null);
const [pagamentos, setPagamentos] = useState<Pagamento[]>([]);
const [documentos, setDocumentos] = useState<Documento[]>([]);
const [expandedPassageiros, setExpandedPassageiros] = useState<Set<number>>(new Set());
const [cachedAt, setCachedAt] = useState<string | null>(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 (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitleAlign: 'center',
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerStyle: { backgroundColor: '#F4F7FE' },
headerTitle: () => (
<Image
source={require('@/assets/icons/logotipo-azul.png')}
style={styles.logo}
resizeMode="contain"
/>
),
}}
/>
{isLoading ? (
<View style={styles.centered}>
<LoadingSpinner size="large" />
</View>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{!!error && <Text style={styles.errorText}>{error}</Text>}
{reservaData && (
<>
{isFromCache && (
<View style={styles.offlineBanner}>
<FontAwesome name="wifi" size={13} color="#fff" />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View>
)}
<Text style={styles.destination}>{reservaData.destino}</Text>
<Section>
{!!reservaData.imagemCidade && (
<Image
source={{ uri: getImageUrl(reservaData.imagemCidade) }}
style={styles.summaryImage}
/>
)}
<View style={styles.rowBetween}>
<View style={styles.idBlock}>
<Text style={styles.idTop}>ID #{reservaData.referenciaViagem}</Text>
<Text style={styles.fieldLabel}>Ref. da Reserva</Text>
</View>
<StatusBadge statusCode={statusCode} />
</View>
<DashedDivider width={0.5} borderStyle="solid" />
<View>
<Text style={styles.idTop}>{reservaData.localizador || '---'}</Text>
<Text style={styles.fieldLabel}>Ref. do Operador</Text>
</View>
</Section>
<Section title="Resumo da Reserva">
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Destino</Text>
<Text style={styles.rowValue}>{reservaData.destino}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Data Viagem</Text>
<Text style={styles.rowValue}>
{formatDateLong(reservaData.startDate)} -{' '}
{formatDateLong(reservaData.endDate)}
</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Nr. de Noites</Text>
<Text style={styles.rowValue}>{reservaData.noites} noites</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Quartos e Passageiros</Text>
<Text style={styles.rowValue}>{buildQuartosPassageiros(reservaData)}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Operador</Text>
<Text style={styles.rowValue}>{reservaData.operador || '---'}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
</Section>
<Section title="Alojamento">
{hotelInfo.length > 0 ? (
hotelInfo.map((hotel, idx) => (
<HotelCard
key={`hotel-${hotel.id_hotel}-${idx}`}
hotel={hotel}
isFirst={idx === 0}
/>
))
) : (
<Text style={styles.empty}>Sem dados de alojamento.</Text>
)}
</Section>
<Section title="Voos">
{voosIda.length > 0 || voosVolta.length > 0 ? (
<View style={styles.vooList}>
{voosIda.length > 0 && (
<VooCard segmentos={voosIda} tipo="ida" />
)}
{voosVolta.length > 0 && (
<VooCard segmentos={voosVolta} tipo="volta" />
)}
</View>
) : (
<Text style={styles.empty}>Sem dados de voos.</Text>
)}
</Section>
<Section title="Passageiros">
<DashedDivider width={1} borderStyle="dashed" />
{reservaData.passageiros && reservaData.passageiros.length > 0 ? (
reservaData.passageiros.map((passageiro, idx) => (
<PassageiroCard
key={`passageiro-${idx}`}
passageiro={passageiro}
index={idx}
isFirst={idx === 0}
isOpen={expandedPassageiros.has(idx)}
onToggle={() => togglePassageiro(idx)}
/>
))
) : (
<Text style={styles.empty}>Sem passageiros associados.</Text>
)}
</Section>
<Section title="Extras">
{extrasData.length > 0 ? (
<View style={styles.extrasList}>
{extrasData.map((extra) => (
<View style={styles.extrasItem} key={extra.name}>
<Text style={styles.extrasItemText}>{extra.name}</Text>
</View>
))}
</View>
) : (
<Text style={styles.empty}>Sem extras associados.</Text>
)}
</Section>
<Section title="Histórico de Pagamentos">
{pagamentos.length > 0 ? (
<>
<View style={styles.tableHeader}>
<View style={styles.tableCellLeft}>
<Text style={styles.tableHeaderText}>Data</Text>
</View>
<View style={styles.tableCellMid}>
<Text style={styles.tableHeaderText}>Valor</Text>
</View>
<View style={styles.tableCellRight}>
<Text style={styles.tableHeaderText}>Meio Pag.</Text>
</View>
</View>
<DashedDivider width={1} borderStyle="dashed" />
{pagamentos.map((pagamento, idx) => (
<React.Fragment key={`pagamento-${idx}`}>
<View
style={[
styles.tableRow,
idx === pagamentos.length - 1 && styles.tableRowLast,
]}>
<View style={styles.tableCellLeft}>
<Text style={styles.tableValueText}>
{formatDateShort(pagamento.data_pagamento || pagamento.data_hora)}
</Text>
</View>
<View style={styles.tableCellMid}>
<Text style={styles.tableValueText}>
{formatCurrency(pagamento.valor)}
</Text>
</View>
<View style={styles.tableCellRight}>
<Text style={styles.tableValueText}>{pagamento.metodoPagamento}</Text>
</View>
</View>
<DashedDivider width={1} borderStyle="dashed" />
</React.Fragment>
))}
</>
) : (
<Text style={styles.empty}>Sem pagamentos registados.</Text>
)}
</Section>
<ValorReservaCard reserva={reservaData} />
<Section title="Documentos">
<DashedDivider width={1} borderStyle="dashed" />
{documentos.length > 0 ? (
documentos.map((documento, idx) => (
<React.Fragment key={`documento-${idx}`}>
<DocumentoRow
documento={documento}
isLast={idx === documentos.length - 1}
/>
<DashedDivider width={1} borderStyle="dashed" />
</React.Fragment>
))
) : (
<Text style={styles.empty}>Sem documentos associados.</Text>
)}
</Section>
{!!cachedAt && (
<Text style={styles.syncLabel}>
Atualizado em: {new Date(cachedAt).toLocaleDateString('pt-PT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
)}
</>
)}
</ScrollView>
)}
</View>
);
}