First commit of the new app
151
assets/components/EmergencyButton.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<GestureDetector gesture={composed}>
|
||||
<Animated.View style={[styles.btn, animatedStyle]}>
|
||||
<FontAwesome name="phone" size={18} color={colors.branco} />
|
||||
<Text style={styles.label}>SOS</Text>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
46
assets/components/LoadingSpinner.tsx
Normal file
@@ -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<ImageStyle>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Image
|
||||
source={require('@/assets/icons/logo.png')}
|
||||
style={[{ width: dimension, height: dimension }, style]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
21
assets/components/reserva/DashedDivider.tsx
Normal file
@@ -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 (
|
||||
<View style={{ height: 1, overflow: 'hidden' }}>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: width,
|
||||
borderColor: color,
|
||||
borderStyle: borderStyle,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
98
assets/components/reserva/DocumentoRow.tsx
Normal file
@@ -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 (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={isDownloading}
|
||||
style={[styles.documentoRow, isLast && styles.documentoRowLast]}>
|
||||
<View style={styles.documentoBadge}>
|
||||
<Text style={styles.documentoBadgeText}>
|
||||
{getDocumentExt(documento.caminhoFicheiro)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.documentoName} numberOfLines={1}>
|
||||
{fileName}
|
||||
</Text>
|
||||
{isDownloading ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : isDownloaded ? (
|
||||
<CircleCheckIcon width={16} height={16} fill="#13AE45" />
|
||||
) : (
|
||||
<FontAwesome name="download" size={16} color={colors.vermelho} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
18
assets/components/reserva/FieldBox.tsx
Normal file
@@ -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 (
|
||||
<View>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<View style={styles.fieldBox}>
|
||||
<Text style={styles.fieldBoxText}>{value || '---'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
47
assets/components/reserva/HotelCard.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.hotelRow, !isFirst && styles.blockBorderTop]}>
|
||||
<Image
|
||||
source={img ? { uri: img } : require('@/assets/icons/logo.png')}
|
||||
style={styles.hotelImage}
|
||||
/>
|
||||
<View style={styles.hotelInfo}>
|
||||
<Stars stars={hotel.stars} />
|
||||
<Text style={styles.hotelName} numberOfLines={2}>
|
||||
{hotel.name}
|
||||
</Text>
|
||||
{!!hotel.regime && (
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Regime: </Text>
|
||||
<Text style={styles.hotelMetaValue}>{hotel.regime}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{!!hotel.data && (
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Check-In: </Text>
|
||||
<Text style={styles.hotelMetaValue}>{formatDateLong(hotel.data)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Nr. de Noites: </Text>
|
||||
<Text style={styles.hotelMetaValue}>
|
||||
{hotel.noites} {hotel.noites === 1 ? 'noite' : 'noites'}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
126
assets/components/reserva/PassageiroCard.tsx
Normal file
@@ -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 (
|
||||
<View>
|
||||
<View style={styles.passageiroContainer}>
|
||||
<Pressable onPress={onToggle} style={styles.passageiroHeaderRow}>
|
||||
<View>
|
||||
<Text style={styles.passageiroSubtitle}>Passageiro {index + 1}</Text>
|
||||
<Text style={styles.passageiroName}>
|
||||
{passageiro.nome} {passageiro.sobrenome}
|
||||
</Text>
|
||||
</View>
|
||||
<Animated.View style={chevronAnimatedStyle}>
|
||||
<FontAwesome name="chevron-down" size={14} color={colors.vermelho} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.passageiroBodyWrap, bodyAnimatedStyle]}
|
||||
pointerEvents={isOpen ? 'auto' : 'none'}>
|
||||
<View
|
||||
style={styles.passageiroBodyMeasure}
|
||||
onLayout={(e) => {
|
||||
const h = Math.ceil(e.nativeEvent.layout.height);
|
||||
if (h > 0) setMeasuredHeight(h);
|
||||
}}>
|
||||
<View style={styles.passageiroBody}>
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Nome" value={passageiro.nome} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Apelido" value={passageiro.sobrenome} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data de Nasc." value={passageiro.dataNascimento} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Género" value={passageiro.genero} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Nacionalidade" value={passageiro.nacionalidade} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Telemóvel" value={passageiro.telemovel} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FieldBox label="Morada" value={passageiro.morada} />
|
||||
<FieldBox label="Nacionalidade Doc." value={passageiro.paisEmissao} />
|
||||
<FieldBox label="Nr Doc Identificação" value={passageiro.numeroDocumento} />
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data Emissão" value={passageiro.dataDeEmissao} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data Val./Exp." value={passageiro.dataDeValidade} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FieldBox label="Email" value={passageiro.email} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<DashedDivider width={1} borderStyle="dashed" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
22
assets/components/reserva/Section.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.summaryCard}>
|
||||
{!!title && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
20
assets/components/reserva/Stars.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.hotelStarsRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<FontAwesome key={`star-${i}`} name="star" size={16} color={colors.stars} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
88
assets/components/reserva/StatusBadge.tsx
Normal file
@@ -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<typeof FontAwesome>['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 (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
type === 'verde' && styles.statusSuccess,
|
||||
type === 'vermelho' && styles.statusDanger,
|
||||
type === 'roxo' && styles.statusInfo,
|
||||
type === 'amarelo' && styles.statusPendente,
|
||||
]}>
|
||||
<View style={styles.statusBadgeContent}>
|
||||
<FontAwesome name={config.icon} size={12} color={color} />
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
type === 'verde' && styles.statusTextSuccess,
|
||||
type === 'vermelho' && styles.statusTextDanger,
|
||||
type === 'roxo' && styles.statusTextInfo,
|
||||
type === 'amarelo' && styles.statusTextPendente,
|
||||
]}>
|
||||
{config.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
72
assets/components/reserva/ValorReservaCard.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.valorSection}>
|
||||
<Text style={styles.sectionTitle}>Valor da Reserva</Text>
|
||||
|
||||
<View style={styles.valorInnerCard}>
|
||||
<Text style={styles.valorAmount}>{formatEuro(reserva.precoTotalFinal)}</Text>
|
||||
<Text style={styles.valorAmountLabel}>Valor Total</Text>
|
||||
|
||||
<Text style={styles.valorPagoLine}>Pago {formatEuro(reserva.valorPago)}</Text>
|
||||
|
||||
<View style={styles.valorProgressTrack}>
|
||||
{percent > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.valorProgressFill,
|
||||
{ width: `${percent}%`, minWidth: 14 },
|
||||
]}>
|
||||
<View style={styles.valorProgressDot} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.valorFaltaRow, isFullyPaid && styles.valorFaltaRowPago]}>
|
||||
{isFullyPaid ? (
|
||||
<View style={styles.valorPagoStatus}>
|
||||
<CircleCheckIcon width={18} height={18} fill={colors.success} />
|
||||
<Text style={styles.valorFaltaLabel}>Pago</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.valorFaltaLabel}>Em Falta</Text>
|
||||
<View style={styles.valorFaltaBadge}>
|
||||
<Text style={styles.valorFaltaBadgeText}>
|
||||
{formatEuro(reserva.valorAPagar)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
158
assets/components/reserva/VooCard.tsx
Normal file
@@ -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<SegmentProps, 'onMapaPress' | 'isLast'>) {
|
||||
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 (
|
||||
<View>
|
||||
<View style={styles.vooRouteRow}>
|
||||
<View style={styles.vooSideCol}>
|
||||
<Text style={styles.vooCode}>{voo.departureAirportCode}</Text>
|
||||
<Text style={styles.vooTime}>{voo.departureTime}</Text>
|
||||
<Text style={styles.vooAirport}>
|
||||
{voo.departureAirportCode}-{voo.departureAirport}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooMidCol}>
|
||||
<Text style={styles.vooDuration}>{voo.flightTime}</Text>
|
||||
<View style={styles.vooPathWrap}>
|
||||
<View style={styles.vooPathLine} />
|
||||
<View style={styles.vooPathIcon}>
|
||||
<FontAwesome
|
||||
name="plane"
|
||||
size={16}
|
||||
color={colors.cinza}
|
||||
style={{ transform: [{ rotate: '45deg' }] }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.vooSideCol, styles.vooSideColRight]}>
|
||||
<Text style={[styles.vooCode, styles.vooCodeRight]}>{voo.arrivalAirportCode}</Text>
|
||||
<Text style={[styles.vooTime, styles.vooTimeRight]}>{voo.arrivalTime}</Text>
|
||||
<Text style={[styles.vooAirport, styles.vooAirportRight]}>
|
||||
{voo.arrivalAirportCode}-{voo.arrivalAirport}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooInfoBox}>
|
||||
<View style={styles.vooInfoColLeft}>
|
||||
<Text style={styles.vooInfoDate}>{formatDateLong(voo.departureDate)}</Text>
|
||||
<View style={styles.vooLegRow}>
|
||||
<LegPlaneIcon {...planeIconProps} />
|
||||
<Text style={styles.vooLegLabel}>{legLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooInfoColRight}>
|
||||
{!!voo.malaLabel && (
|
||||
<Text style={styles.vooMetaLine}>
|
||||
<Text style={styles.vooMetaLabel}>Bagagem: </Text>
|
||||
<Text style={styles.vooMetaValue}>{voo.malaLabel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{!!voo.class && (
|
||||
<Text style={styles.vooMetaLine}>
|
||||
<Text style={styles.vooMetaLabel}>Classe: </Text>
|
||||
<Text style={styles.vooMetaValue}>{voo.class}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.mapaBtn} onPress={handleMapaPress}>
|
||||
<Text style={styles.mapaBtnText}>
|
||||
Mapa {voo.departureAirportCode}
|
||||
</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.mapaBtnIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.vooCard}>
|
||||
{temEscalas && (
|
||||
<View style={styles.vooEscalasBadge}>
|
||||
<FontAwesome name="random" size={14} color={colors.azul} />
|
||||
<Text style={styles.vooEscalasBadgeText}>
|
||||
{segmentos.length - 1} {segmentos.length - 1 === 1 ? 'escala' : 'escalas'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{segmentos.map((voo, idx) => (
|
||||
<View key={`seg-${idx}`}>
|
||||
<VooSegmentCard
|
||||
voo={voo}
|
||||
tipo={tipo}
|
||||
/>
|
||||
|
||||
{idx < segmentos.length - 1 && (
|
||||
<View style={styles.vooEscalaDivider}>
|
||||
<View style={styles.vooEscalaLine} />
|
||||
<View style={styles.vooEscalaChip}>
|
||||
<FontAwesome name="clock-o" size={10} color="#4A6592" />
|
||||
<Text style={styles.vooEscalaChipText}>
|
||||
Escala em {voo.arrivalAirportCode}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.vooEscalaLine} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
30
assets/components/reserva/formatters.ts
Normal file
@@ -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}`;
|
||||
};
|
||||
66
assets/config/api.ts
Normal file
@@ -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;
|
||||
222
assets/contexts/useAuth.tsx
Normal file
@@ -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<AuthContextType | undefined>(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<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userData, setUserData] = useState<UserData | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
BIN
assets/fonts/SpaceGrotesk-Bold.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Light.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Medium.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-Regular.ttf
Normal file
BIN
assets/fonts/SpaceGrotesk-SemiBold.ttf
Normal file
28
assets/icons/StatusBadgeIcon.tsx
Normal file
@@ -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 <CircleCheckIcon {...iconProps} />;
|
||||
case 'vermelho':
|
||||
return <CircleXmarkIcon {...iconProps} />;
|
||||
case 'amarelo':
|
||||
return <ClockIcon {...iconProps} />;
|
||||
case 'roxo':
|
||||
return <ShipIcon {...iconProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
BIN
assets/icons/calendario.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
assets/icons/chevron-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M471.1 297.4C483.6 309.9 483.6 330.2 471.1 342.7L279.1 534.7C266.6 547.2 246.3 547.2 233.8 534.7C221.3 522.2 221.3 501.9 233.8 489.4L403.2 320L233.9 150.6C221.4 138.1 221.4 117.8 233.9 105.3C246.4 92.8 266.7 92.8 279.2 105.3L471.2 297.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 468 B |
1
assets/icons/circle-check-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
1
assets/icons/circle-xmark-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 511 B |
1
assets/icons/clock-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M256 0a256 256 0 1 1 0 512A256 256 0 1 1 256 0zM232 120l0 136c0 8 4 15.5 10.7 20l96 64c11 7.4 25.9 4.4 33.3-6.7s4.4-25.9-6.7-33.3L280 243.2 280 120c0-13.3-10.7-24-24-24s-24 10.7-24 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 415 B |
BIN
assets/icons/confirmadas.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/contactos-fill.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/contactos.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/email.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/home-selecionado.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/home.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/logotipo-azul.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/icons/logotipo-branco.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
assets/icons/logout-fill.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/icons/mail.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/icons/mala.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/icons/perfil-apagar-fill.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/icons/perfil-fill.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/icons/perfil-selecionado.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/perfil.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
assets/icons/plane-arrival-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M418.6 257.1L297.9 67.7C293.1 60.1 285.3 54.9 276.5 53.4L233.4 45.8C222.6 43.9 213.2 53.1 214.8 63.9L238.8 225.5L133.8 207L100 145.2C96.5 138.7 90.2 134.2 83.1 133L66 130C56.2 128.3 47.2 135.9 47.2 145.8L47.8 252.1C48 283 70.2 309.4 100.7 314.8L114.2 317.2L114.2 317.2L531.8 390.8C562.3 396.2 591.3 375.8 596.7 345.4C602.1 315 581.7 285.9 551.3 280.5L418.6 257.1zM256 448C273.7 448 288 433.7 288 416C288 398.3 273.7 384 256 384C238.3 384 224 398.3 224 416C224 433.7 238.3 448 256 448zM387.2 432.7C387.2 415 372.9 400.7 355.2 400.7C337.5 400.7 323.2 415 323.2 432.7C323.2 450.4 337.5 464.7 355.2 464.7C372.9 464.7 387.2 450.4 387.2 432.7zM64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L576 576C593.7 576 608 561.7 608 544C608 526.3 593.7 512 576 512L64 512z"/></svg>
|
||||
|
After Width: | Height: | Size: 997 B |
1
assets/icons/plane-departure-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M404 207.9L204.7 104.2C196.7 100.1 187.4 99.4 179 102.5L137.9 117.5C127.6 121.2 124.1 133.9 130.8 142.5L232.3 270.4L132.1 306.8L72 270.2C65.8 266.4 58.2 265.7 51.3 268.1L35 274.1C25.6 277.5 21.6 288.6 26.7 297.2L80.3 389C95.9 415.7 128.4 427.4 157.4 416.8L170.3 412.1L170.3 412.1L568.7 267.1C597.8 256.5 612.7 224.4 602.2 195.3C591.7 166.2 559.5 151.3 530.4 161.8L404 207.9zM64.2 512C46.5 512 32.2 526.3 32.2 544C32.2 561.7 46.5 576 64.2 576L576.2 576C593.9 576 608.2 561.7 608.2 544C608.2 526.3 593.9 512 576.2 512L64.2 512z"/></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
1
assets/icons/right-from-bracket-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M569 337C578.4 327.6 578.4 312.4 569 303.1L425 159C418.1 152.1 407.8 150.1 398.8 153.8C389.8 157.5 384 166.3 384 176L384 256L272 256C245.5 256 224 277.5 224 304L224 336C224 362.5 245.5 384 272 384L384 384L384 464C384 473.7 389.8 482.5 398.8 486.2C407.8 489.9 418.1 487.9 425 481L569 337zM224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160z"/></svg>
|
||||
|
After Width: | Height: | Size: 783 B |
BIN
assets/icons/seta-up-fill.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/icons/seta-up.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
1
assets/icons/ship-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M192 32c0-17.7 14.3-32 32-32L352 0c17.7 0 32 14.3 32 32l0 32 48 0c26.5 0 48 21.5 48 48l0 128 44.4 14.8c23.1 7.7 29.5 37.5 11.5 53.9l-101 92.6c-16.2 9.4-34.7 15.1-50.9 15.1c-19.6 0-40.8-7.7-59.2-20.3c-22.1-15.5-51.6-15.5-73.7 0c-17.1 11.8-38 20.3-59.2 20.3c-16.2 0-34.7-5.7-50.9-15.1l-101-92.6c-18-16.5-11.6-46.2 11.5-53.9L96 240l0-128c0-26.5 21.5-48 48-48l48 0 0-32zM160 218.7l107.8-35.9c13.1-4.4 27.3-4.4 40.5 0L416 218.7l0-90.7-256 0 0 90.7zM306.5 421.9C329 437.4 356.5 448 384 448c26.9 0 55.4-10.8 77.4-26.1c0 0 0 0 0 0c11.9-8.5 28.1-7.8 39.2 1.7c14.4 11.9 32.5 21 50.6 25.2c17.2 4 27.9 21.2 23.9 38.4s-21.2 27.9-38.4 23.9c-24.5-5.7-44.9-16.5-58.2-25C449.5 501.7 417 512 384 512c-31.9 0-60.6-9.9-80.4-18.9c-5.8-2.7-11.1-5.3-15.6-7.7c-4.5 2.4-9.7 5.1-15.6 7.7c-19.8 9-48.5 18.9-80.4 18.9c-33 0-65.5-10.3-94.5-25.8c-13.4 8.4-33.7 19.3-58.2 25c-17.2 4-34.4-6.7-38.4-23.9s6.7-34.4 23.9-38.4c18.1-4.2 36.2-13.3 50.6-25.2c11.1-9.4 27.3-10.1 39.2-1.7c0 0 0 0 0 0C136.7 437.2 165.1 448 192 448c27.5 0 55-10.6 77.5-26.1c11.1-7.9 25.9-7.9 37 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/telefone.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
1
assets/icons/user-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 312C386.3 312 440 258.3 440 192C440 125.7 386.3 72 320 72C253.7 72 200 125.7 200 192C200 258.3 253.7 312 320 312zM290.3 368C191.8 368 112 447.8 112 546.3C112 562.7 125.3 576 141.7 576L498.3 576C514.7 576 528 562.7 528 546.3C528 447.8 448.2 368 349.7 368L290.3 368z"/></svg>
|
||||
|
After Width: | Height: | Size: 500 B |
1
assets/icons/user-xmark-solid-full.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M286.1 368C384.6 368 464.4 447.8 464.4 546.3C464.4 562.7 451.1 576 434.7 576L78.1 576C61.7 576 48.4 562.7 48.4 546.3C48.4 447.8 128.2 368 226.7 368L286.1 368zM562.3 172.1C571.7 162.7 586.9 162.7 596.2 172.1C605.5 181.5 605.6 196.7 596.2 206L562.3 239.9L596.2 273.8C605.6 283.2 605.6 298.4 596.2 307.7C586.8 317 571.6 317.1 562.3 307.7L528.4 273.8L494.5 307.7C485.1 317.1 469.9 317.1 460.6 307.7C451.3 298.3 451.2 283.1 460.6 273.8L494.5 239.9L460.6 206C451.2 196.6 451.2 181.4 460.6 172.1C470 162.8 485.2 162.7 494.5 172.1L528.4 206L562.3 172.1zM256.4 312C190.1 312 136.4 258.3 136.4 192C136.4 125.7 190.1 72 256.4 72C322.7 72 376.4 125.7 376.4 192C376.4 258.3 322.7 312 256.4 312z"/></svg>
|
||||
|
After Width: | Height: | Size: 912 B |
BIN
assets/icons/whatsapp.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/banner-login.png
Normal file
|
After Width: | Height: | Size: 660 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
146
assets/services/documentSync.ts
Normal file
@@ -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<void> => {
|
||||
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<string | null> => {
|
||||
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<void> => {
|
||||
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<string | null> => {
|
||||
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<string | null> => {
|
||||
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<DocumentosChecksumsResponse | null> => {
|
||||
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<DocumentoChecksum[]> => {
|
||||
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 };
|
||||
};
|
||||
229
assets/services/offlineStorage.ts
Normal file
@@ -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<void> => {
|
||||
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<Reserva[] | null> => {
|
||||
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<void> => {
|
||||
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<ReservaData | null> => {
|
||||
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<void> => {
|
||||
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<CachedReservaFull | null> => {
|
||||
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<void> => {
|
||||
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<Date | null> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<any | null> => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
31
assets/styles/colors.tsx
Normal file
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
7
assets/styles/fonts.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const fonts = {
|
||||
regular: 'SpaceGrotesk-Regular',
|
||||
medium: 'SpaceGrotesk-Medium',
|
||||
bold: 'SpaceGrotesk-Bold',
|
||||
semiBold: 'SpaceGrotesk-SemiBold',
|
||||
light: 'SpaceGrotesk-Light',
|
||||
};
|
||||
345
assets/types/index.tsx
Normal file
@@ -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<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (nome?: string, apelido?: string, oldPassword?: string, newPassword?: string) => Promise<void>;
|
||||
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[];
|
||||
}
|
||||