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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`;
};