First commit of the new app
This commit is contained in:
333
app/(tabs)/home/index.tsx
Normal file
333
app/(tabs)/home/index.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { useAuth } from '@/assets/contexts/useAuth';
|
||||
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||
import { cacheReservas, getCachedReservas } from '@/assets/services/offlineStorage';
|
||||
import { downloadDocumentos, getDocumentosChecksums, verificarDocumentosParaDownload } from '@/assets/services/documentSync';
|
||||
import { Reserva, ReservasResponse } from '@/assets/types';
|
||||
import { Href, router } from 'expo-router';
|
||||
import styles from '@/styles/screens/tabs/home.styles';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
|
||||
const getStatusPriority = (statusCode: string): number => {
|
||||
switch (statusCode) {
|
||||
case '10':
|
||||
return 1; // Em viagem
|
||||
case '1':
|
||||
case '5':
|
||||
return 2; // Confirmada
|
||||
case '-1':
|
||||
return 3; // Em pagamento
|
||||
case '-2':
|
||||
case '-3':
|
||||
return 4; // Pendente
|
||||
case '99':
|
||||
return 5; // Viagem realizada
|
||||
case '-5':
|
||||
return 7; // Cancelada — sempre no fim
|
||||
default:
|
||||
return 6;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { token } = useAuth();
|
||||
const [reservas, setReservas] = useState<Reserva[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFromCache, setIsFromCache] = useState(false);
|
||||
|
||||
const syncDocumentos = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const checksumsData = await getDocumentosChecksums(token);
|
||||
if (!checksumsData || checksumsData.status !== 200 || !checksumsData.documentos?.length) return;
|
||||
|
||||
const documentosParaDownload = await verificarDocumentosParaDownload(checksumsData.documentos);
|
||||
if (!documentosParaDownload.length) return;
|
||||
|
||||
Alert.alert(
|
||||
'Documentos disponíveis',
|
||||
`Existem ${documentosParaDownload.length} ficheiro${documentosParaDownload.length === 1 ? '' : 's'} novo${documentosParaDownload.length === 1 ? '' : 's'} para descarregar/atualizar. Pretende descarregá-los agora?`,
|
||||
[
|
||||
{ text: 'Não', style: 'cancel' },
|
||||
{
|
||||
text: 'Sim',
|
||||
onPress: () => downloadDocumentos(documentosParaDownload),
|
||||
},
|
||||
],
|
||||
);
|
||||
} catch {
|
||||
// sync de documentos é silencioso — não bloqueia o utilizador
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReservas = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (!token) {
|
||||
setReservas([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.USER_RESERVAS);
|
||||
const formData = new FormData();
|
||||
formData.append('token', token);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data: ReservasResponse = await response.json();
|
||||
|
||||
if (!response.ok || (data.status !== 200 && data.status !== '200')) {
|
||||
throw new Error(data.message || 'Erro ao carregar reservas');
|
||||
}
|
||||
|
||||
const orderedReservas = [...(data.reservas || [])].sort((a, b) => {
|
||||
const priorityDiff =
|
||||
getStatusPriority(a.statusCode) - getStatusPriority(b.statusCode);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return new Date(a.startDate).getTime() - new Date(b.startDate).getTime();
|
||||
});
|
||||
|
||||
setReservas(orderedReservas);
|
||||
setIsFromCache(false);
|
||||
await cacheReservas(orderedReservas);
|
||||
syncDocumentos();
|
||||
} catch {
|
||||
const cached = await getCachedReservas();
|
||||
if (cached && cached.length > 0) {
|
||||
setReservas(cached);
|
||||
setIsFromCache(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Sem ligação à internet e sem dados guardados.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReservas();
|
||||
}, [token]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchReservas();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now);
|
||||
const total = reservas.length;
|
||||
const confirmadas = reservas.filter((reserva) => reserva.statusCode === '1').length;
|
||||
const proxima = proximas[0];
|
||||
|
||||
let dias = '--';
|
||||
if (proxima) {
|
||||
const diff = new Date(proxima.startDate).getTime() - now.getTime();
|
||||
dias = `${Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))}`;
|
||||
}
|
||||
|
||||
const reservaEmViagem = reservas.find((reserva) => reserva.statusCode === '10');
|
||||
|
||||
return {
|
||||
dias,
|
||||
total,
|
||||
confirmadas,
|
||||
emViagem: Boolean(reservaEmViagem),
|
||||
destinoEmViagem: reservaEmViagem?.destino?.trim() || '—',
|
||||
};
|
||||
}, [reservas]);
|
||||
|
||||
const getStatusType = (statusCode: string) => {
|
||||
if (statusCode === '1' || statusCode === '5') return 'verde';
|
||||
if (statusCode === '-5') return 'vermelho';
|
||||
if (statusCode === '10' || statusCode === '99') return 'roxo';
|
||||
if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const getStatusColor = (statusType: string) => {
|
||||
switch (statusType) {
|
||||
case 'verde':
|
||||
return '#13AE45';
|
||||
case 'vermelho':
|
||||
return '#E6463B';
|
||||
case 'roxo':
|
||||
return '#B138E6';
|
||||
case 'amarelo':
|
||||
return '#C99700';
|
||||
default:
|
||||
return '#4A6592';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (statusCode: string) => {
|
||||
switch (statusCode) {
|
||||
case '1':
|
||||
return { label: 'Confirmada', icon: 'check-circle' as const };
|
||||
case '5':
|
||||
return { label: 'Confirmada*', icon: 'check-circle' as const };
|
||||
case '-5':
|
||||
return { label: 'Cancelada', icon: 'times-circle' as const };
|
||||
case '10':
|
||||
return { label: 'Em Viagem', icon: 'ship' as const };
|
||||
case '99':
|
||||
return { label: 'Viagem Realizada', icon: 'ship' as const };
|
||||
case '-2':
|
||||
case '-3':
|
||||
return { label: 'Pendente', icon: 'clock-o' as const };
|
||||
case '-1':
|
||||
return { label: 'Em pagamento', icon: 'clock-o' as const };
|
||||
default:
|
||||
return { label: 'Não definido', icon: 'question-circle' as const };
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(reservas);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={[styles.statCard, styles.statCardPrimary]}>
|
||||
{stats.emViagem ? (
|
||||
<View style={styles.statIconEmViagemWrap}>
|
||||
<FontAwesome name="ship" size={20} color={colors.azul} />
|
||||
</View>
|
||||
) : (
|
||||
<Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} />
|
||||
)}
|
||||
<Text style={styles.statLabelPrimary}>
|
||||
{stats.emViagem ? 'Em viagem' : 'Proxima Viagem'}
|
||||
</Text>
|
||||
<Text style={styles.statValuePrimary} numberOfLines={stats.emViagem ? 2 : 1}>
|
||||
{stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} />
|
||||
<Text style={styles.statLabel}>Total Reservas</Text>
|
||||
<Text style={styles.statValue}>{stats.total}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} />
|
||||
<Text style={styles.statLabel}>Confirmadas</Text>
|
||||
<Text style={styles.statValue}>{stats.confirmadas}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isFromCache && (
|
||||
<View style={styles.offlineBanner}>
|
||||
<FontAwesome name="wifi" size={13} color={colors.branco} />
|
||||
<Text style={styles.offlineBannerText}>Sem ligação — a mostrar dados guardados</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>As minhas Reservas</Text>
|
||||
|
||||
{!!error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
{!error && reservas.length === 0 && (
|
||||
<Text style={styles.emptyText}>Sem reservas para apresentar.</Text>
|
||||
)}
|
||||
|
||||
{!error &&
|
||||
reservas.map((reserva, index) => {
|
||||
const statusType = getStatusType(reserva.statusCode);
|
||||
const statusConfig = getStatusConfig(reserva.statusCode);
|
||||
const statusColor = getStatusColor(statusType);
|
||||
return (
|
||||
<Pressable
|
||||
key={`${reserva.referenciaViagem}-${index}`}
|
||||
style={styles.card}
|
||||
onPress={() =>
|
||||
router.push(`/reserva/${encodeURIComponent(reserva.referenciaViagem || '')}` as Href)
|
||||
}>
|
||||
<Image
|
||||
source={
|
||||
reserva.imagemCidade
|
||||
? { uri: reserva.imagemCidade }
|
||||
: require('@/assets/icons/logo.png')
|
||||
}
|
||||
style={styles.cardImage}
|
||||
/>
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.cardTopRow}>
|
||||
<Text style={styles.refText}>{reserva.referenciaViagem || '---'}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
statusType === 'verde' && styles.statusSuccess,
|
||||
statusType === 'vermelho' && styles.statusDanger,
|
||||
statusType === 'roxo' && styles.statusInfo,
|
||||
statusType === 'amarelo' && styles.statusPendente,
|
||||
]}>
|
||||
<View style={styles.statusBadgeContent}>
|
||||
<FontAwesome name={statusConfig.icon} size={12} color={statusColor} />
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
statusType === 'verde' && styles.statusTextSuccess,
|
||||
statusType === 'vermelho' && styles.statusTextDanger,
|
||||
statusType === 'roxo' && styles.statusTextInfo,
|
||||
statusType === 'amarelo' && styles.statusTextPendente,
|
||||
]}>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.destination}>{reserva.destino}</Text>
|
||||
<Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text>
|
||||
<View style={styles.dateRow}>
|
||||
<FontAwesome name="calendar" size={12} color="#4A6592" />
|
||||
<Text style={styles.meta}>{reserva.startDate}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.arrowWrap}>
|
||||
<FontAwesome name="angle-right" size={20} color="#EB2415" />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user