first version of the cruise lovers app, build 1.0.1, ready to be published on the app store

This commit is contained in:
2026-07-22 09:09:26 +01:00
parent 104566ded3
commit 4f5c5cc6d2
23 changed files with 1225 additions and 858 deletions

View File

@@ -1,51 +1,69 @@
{ {
"expo": { "expo": {
"name": "cruise lovers", "name": "Cruise Lovers",
"slug": "cruiseLovers", "slug": "cruiseLovers",
"version": "1.0.0", "version": "1.0.1",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icons/logo.png", "icon": "./assets/icons/app-icon.png",
"scheme": "cruiselovers", "scheme": "cruiselovers",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": false,
"infoPlist": { "infoPlist": {
"LSApplicationQueriesSchemes": ["comgooglemaps", "waze"] "LSApplicationQueriesSchemes": [
} "comgooglemaps",
"waze"
],
"ITSAppUsesNonExemptEncryption": false,
"NSLocationWhenInUseUsageDescription": "A Cruise Lovers utiliza a localização apenas para mostrar o mapa da agência e ajudar a obter direções."
},
"bundleIdentifier": "pt.cruiselovers.app"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#ffffff",
"foregroundImage": "./assets/icons/logo.png", "foregroundImage": "./assets/icons/app-icon.png",
"backgroundImage": "./assets/icons/logo.png", "backgroundImage": "./assets/icons/app-icon.png",
"monochromeImage": "./assets/icons/logo.png" "monochromeImage": "./assets/icons/app-icon.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "pt.cruiselovers.app"
}, },
"web": { "web": {
"output": "static", "output": "static",
"favicon": "./assets/icons/logo.png" "favicon": "./assets/icons/app-icon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"image": "./assets/icons/logo.png", "image": "./assets/icons/app-icon.png",
"imageWidth": 200, "imageWidth": 200,
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"dark": { }
"backgroundColor": "#000000" ],
}, "expo-font",
[
"expo-maps",
{
"requestLocationPermission": true,
"locationPermission": "A Cruise Lovers utiliza a localização apenas para mostrar o mapa da agência e ajudar a obter direções."
} }
] ]
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
"reactCompiler": true "reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "28650db4-e205-490d-8d1f-5112bcd9b644"
}
} }
} }
} }

View File

@@ -1,4 +1,7 @@
import { useAuth } from '@/assets/contexts/useAuth';
import styles from '@/styles/screens/auth/login.styles';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router, type Href } from 'expo-router';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Image, Image,
@@ -7,14 +10,11 @@ import {
Platform, Platform,
Pressable, Pressable,
ScrollView, ScrollView,
StatusBar,
Text, Text,
TextInput, TextInput,
View, View,
StatusBar,
} from 'react-native'; } from 'react-native';
import styles from '@/styles/screens/auth/login.styles';
import { useAuth } from '@/assets/contexts/useAuth';
import { router, type Href } from 'expo-router';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -69,7 +69,7 @@ export default function Login() {
<TextInput <TextInput
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
placeholder="hello@domain.pt" placeholder="login@cruiselovers.pt"
placeholderTextColor="#9AA0A6" placeholderTextColor="#9AA0A6"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"

View File

@@ -53,7 +53,7 @@ export default function RecoverEmailScreen() {
<TextInput <TextInput
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
placeholder="hello@domain.pt" placeholder="recuperar@cruiselovers.pt"
placeholderTextColor="#9AA0A6" placeholderTextColor="#9AA0A6"
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"

View File

@@ -1,3 +1,5 @@
import { ContactMapPreview } from '@/assets/components/ContactMapPreview';
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth'; import { useAuth } from '@/assets/contexts/useAuth';
import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage'; import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage';
@@ -7,7 +9,6 @@ import styles from '@/styles/screens/tabs/contactos.styles';
import { FontAwesome } from '@expo/vector-icons'; import { FontAwesome } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { import {
Alert, Alert,
Image, Image,
@@ -77,7 +78,6 @@ const parseCoords = (raw?: string | null): { lat: number; lng: number } | null =
return null; return null;
}; };
const parseHorarios = (raw: string): { dia: string; horas: string }[] => { const parseHorarios = (raw: string): { dia: string; horas: string }[] => {
return raw return raw
.split('\n') .split('\n')
@@ -114,9 +114,6 @@ export default function Contactos() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [mapLoading, setMapLoading] = useState(true);
const [mapError, setMapError] = useState(false);
const fetchContacts = async () => { const fetchContacts = async () => {
// 1. Mostrar cache imediatamente enquanto atualiza em fundo // 1. Mostrar cache imediatamente enquanto atualiza em fundo
const cached = await getCachedContacts(); const cached = await getCachedContacts();
@@ -138,8 +135,6 @@ export default function Contactos() {
const data: ContactResponse = await response.json(); const data: ContactResponse = await response.json();
console.log('data', data);
if (data.status === 200 && data.contact) { if (data.status === 200 && data.contact) {
const validSocials = (data.socials || []).filter((s) => s.value?.trim()); const validSocials = (data.socials || []).filter((s) => s.value?.trim());
setContactData(data.contact); setContactData(data.contact);
@@ -186,9 +181,6 @@ export default function Contactos() {
const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : []; const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : [];
const coords = parseCoords(contactData?.coordenadas); const coords = parseCoords(contactData?.coordenadas);
const mapUrl = coords
? `https://staticmap.openstreetmap.de/staticmap.php?center=${coords.lat},${coords.lng}&zoom=15&size=600x300&markers=${coords.lat},${coords.lng},red-pushpin`
: null;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -249,30 +241,13 @@ export default function Contactos() {
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>Localização</Text> <Text style={styles.cardTitle}>Localização</Text>
{!!mapUrl && ( {coords && (
<Pressable <ContactMapPreview
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)} lat={coords.lat}
style={styles.mapImageWrap}> lng={coords.lng}
<Image title={contactData.address}
source={{ uri: mapUrl }} onPress={() => openMaps(contactData.address, coords.lat, coords.lng)}
style={styles.mapImage}
resizeMode="cover"
onLoadStart={() => { setMapLoading(true); setMapError(false); }}
onLoad={() => setMapLoading(false)}
onError={() => { setMapLoading(false); setMapError(true); }}
/> />
{mapLoading && !mapError && (
<View style={styles.mapOverlay}>
<LoadingSpinner size="small" />
</View>
)}
{mapError && (
<View style={styles.mapOverlay}>
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
</View>
)}
</Pressable>
)} )}
<View style={styles.addressRow}> <View style={styles.addressRow}>

View File

@@ -1,13 +1,18 @@
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 { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth';
import ConfirmadasIcon from '@/assets/icons/icon-reservas-confirmadas.svg';
import MalaIcon from '@/assets/icons/icon-mala.svg';
import TotalReservasIcon from '@/assets/icons/icon-total-reservas.svg';
import WifiIcon from '@/assets/icons/wifi-solid.svg';
import { downloadDocumentos, getDocumentosChecksums, verificarDocumentosParaDownload } from '@/assets/services/documentSync';
import { cacheReservas, getCachedReservas } from '@/assets/services/offlineStorage';
import { colors } from '@/assets/styles/colors';
import { Reserva, ReservasResponse } from '@/assets/types';
import styles from '@/styles/screens/tabs/home.styles';
import { FontAwesome } from '@expo/vector-icons';
import { Href, router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Image, Image,
@@ -17,7 +22,6 @@ import {
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { FontAwesome } from '@expo/vector-icons';
const getStatusPriority = (statusCode: string): number => { const getStatusPriority = (statusCode: string): number => {
switch (statusCode) { switch (statusCode) {
@@ -40,9 +44,19 @@ const getStatusPriority = (statusCode: string): number => {
} }
}; };
type HomeFilter = 'proxima' | 'total' | 'confirmadas';
const sortReservas = (items: Reserva[]) =>
[...items].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();
});
export default function Home() { export default function Home() {
const { token } = useAuth(); const { token } = useAuth();
const [reservas, setReservas] = useState<Reserva[]>([]); const [reservas, setReservas] = useState<Reserva[]>([]);
const [activeFilter, setActiveFilter] = useState<HomeFilter>('total');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -95,19 +109,14 @@ export default function Home() {
body: formData, body: formData,
}); });
const data: ReservasResponse = await response.json(); const data: ReservasResponse = await response.json();
if (!response.ok || (data.status !== 200 && data.status !== '200')) { if (!response.ok || (data.status !== 200 && data.status !== '200')) {
throw new Error(data.message || 'Erro ao carregar reservas'); throw new Error(data.message || 'Erro ao carregar reservas');
} }
const orderedReservas = [...(data.reservas || [])].sort((a, b) => { const orderedReservas = sortReservas(data.reservas || []);
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); setReservas(orderedReservas);
setIsFromCache(false); setIsFromCache(false);
@@ -144,8 +153,10 @@ export default function Home() {
const now = new Date(); const now = new Date();
const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now); const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now);
const total = reservas.length; const total = reservas.length;
const confirmadas = reservas.filter((reserva) => reserva.statusCode === '1').length; const confirmadas = reservas.filter(
const proxima = proximas[0]; (reserva) => reserva.statusCode === '1' || reserva.statusCode === '5'
).length;
const proxima = sortReservas(proximas)[0];
let dias = '--'; let dias = '--';
if (proxima) { if (proxima) {
@@ -161,9 +172,55 @@ export default function Home() {
confirmadas, confirmadas,
emViagem: Boolean(reservaEmViagem), emViagem: Boolean(reservaEmViagem),
destinoEmViagem: reservaEmViagem?.destino?.trim() || '—', destinoEmViagem: reservaEmViagem?.destino?.trim() || '—',
reservaEmViagem,
proximaReserva: proxima ?? null,
}; };
}, [reservas]); }, [reservas]);
const filteredReservas = useMemo(() => {
const now = new Date();
if (activeFilter === 'total') {
return sortReservas(reservas);
}
if (activeFilter === 'confirmadas') {
return sortReservas(
reservas.filter(
(reserva) => reserva.statusCode === '1' || reserva.statusCode === '5'
)
);
}
if (stats.reservaEmViagem) {
return [stats.reservaEmViagem];
}
const proxima = reservas
.filter((reserva) => new Date(reserva.startDate) >= now)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())[0];
return proxima ? [proxima] : [];
}, [activeFilter, reservas, stats.reservaEmViagem]);
const sectionTitle = useMemo(() => {
if (activeFilter === 'confirmadas') return 'Reservas Confirmadas';
if (activeFilter === 'proxima') {
return stats.emViagem ? 'Em Viagem' : 'Próxima Viagem';
}
return 'As minhas Reservas';
}, [activeFilter, stats.emViagem]);
const emptyMessage = useMemo(() => {
if (activeFilter === 'confirmadas') return 'Sem reservas confirmadas.';
if (activeFilter === 'proxima') {
return stats.emViagem
? 'Sem viagem em curso.'
: 'Sem viagens futuras para apresentar.';
}
return 'Sem reservas para apresentar.';
}, [activeFilter, stats.emViagem]);
const getStatusType = (statusCode: string) => { const getStatusType = (statusCode: string) => {
if (statusCode === '1' || statusCode === '5') return 'verde'; if (statusCode === '1' || statusCode === '5') return 'verde';
if (statusCode === '-5') return 'vermelho'; if (statusCode === '-5') return 'vermelho';
@@ -217,7 +274,9 @@ export default function Home() {
); );
} }
console.log(reservas); const isProximaActive = activeFilter === 'proxima';
const isTotalActive = activeFilter === 'total';
const isConfirmadasActive = activeFilter === 'confirmadas';
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -227,52 +286,78 @@ export default function Home() {
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}> refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={[styles.statCard, styles.statCardPrimary]}> <Pressable
style={[styles.statCard, isProximaActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('proxima')}>
{stats.emViagem ? ( {stats.emViagem ? (
<View style={styles.statIconEmViagemWrap}> <View style={styles.statIconEmViagemWrap}>
<FontAwesome name="ship" size={20} color={colors.azul} /> <FontAwesome name="ship" size={20} color={colors.azul} />
</View> </View>
) : ( ) : isProximaActive ? (
<Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} /> <Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} />
) : (
<MalaIcon width={38} height={38} style={styles.statIconSvg} />
)} )}
<Text style={styles.statLabelPrimary}> <Text style={isProximaActive ? styles.statLabelPrimary : styles.statLabel}>
{stats.emViagem ? 'Em viagem' : 'Proxima Viagem'} {stats.emViagem ? 'Em viagem' : 'Proxima Viagem'}
</Text> </Text>
<Text style={styles.statValuePrimary} numberOfLines={stats.emViagem ? 2 : 1}> <Text
style={isProximaActive ? styles.statValuePrimary : styles.statValue}
numberOfLines={stats.emViagem ? 2 : 1}>
{stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`} {stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`}
</Text> </Text>
</View> </Pressable>
<View style={styles.statCard}> <Pressable
style={[styles.statCard, isTotalActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('total')}>
{isTotalActive ? (
<TotalReservasIcon width={38} height={38} style={styles.statIconSvg} />
) : (
<Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} /> <Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} />
<Text style={styles.statLabel}>Total Reservas</Text> )}
<Text style={styles.statValue}>{stats.total}</Text> <Text style={isTotalActive ? styles.statLabelPrimary : styles.statLabel}>
</View> Total Reservas
</Text>
<Text style={isTotalActive ? styles.statValuePrimary : styles.statValue}>
{stats.total}
</Text>
</Pressable>
<View style={styles.statCard}> <Pressable
style={[styles.statCard, isConfirmadasActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('confirmadas')}>
{isConfirmadasActive ? (
<ConfirmadasIcon width={38} height={38} style={styles.statIconSvg} />
) : (
<Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} /> <Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} />
<Text style={styles.statLabel}>Confirmadas</Text> )}
<Text style={styles.statValue}>{stats.confirmadas}</Text> <Text style={isConfirmadasActive ? styles.statLabelPrimary : styles.statLabel}>
</View> Confirmadas
</Text>
<Text style={isConfirmadasActive ? styles.statValuePrimary : styles.statValue}>
{stats.confirmadas}
</Text>
</Pressable>
</View> </View>
{isFromCache && ( {isFromCache && (
<View style={styles.offlineBanner}> <View style={styles.offlineBanner}>
<FontAwesome name="wifi" size={13} color={colors.branco} /> <WifiIcon width={13} height={13} fill={colors.branco} />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text> <Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View> </View>
)} )}
<Text style={styles.sectionTitle}>As minhas Reservas</Text> <Text style={styles.sectionTitle}>{sectionTitle}</Text>
{!!error && <Text style={styles.errorText}>{error}</Text>} {!!error && <Text style={styles.errorText}>{error}</Text>}
{!error && reservas.length === 0 && ( {!error && filteredReservas.length === 0 && (
<Text style={styles.emptyText}>Sem reservas para apresentar.</Text> <Text style={styles.emptyText}>{emptyMessage}</Text>
)} )}
{!error && {!error &&
reservas.map((reserva, index) => { filteredReservas.map((reserva, index) => {
const statusType = getStatusType(reserva.statusCode); const statusType = getStatusType(reserva.statusCode);
const statusConfig = getStatusConfig(reserva.statusCode); const statusConfig = getStatusConfig(reserva.statusCode);
const statusColor = getStatusColor(statusType); const statusColor = getStatusColor(statusType);
@@ -305,6 +390,8 @@ export default function Home() {
<View style={styles.statusBadgeContent}> <View style={styles.statusBadgeContent}>
<FontAwesome name={statusConfig.icon} size={12} color={statusColor} /> <FontAwesome name={statusConfig.icon} size={12} color={statusColor} />
<Text <Text
numberOfLines={1}
ellipsizeMode="tail"
style={[ style={[
styles.statusText, styles.statusText,
statusType === 'verde' && styles.statusTextSuccess, statusType === 'verde' && styles.statusTextSuccess,
@@ -318,7 +405,9 @@ export default function Home() {
</View> </View>
</View> </View>
<Text style={styles.destination}>{reserva.destino}</Text> <Text style={styles.destination} numberOfLines={1} ellipsizeMode="tail">
{reserva.destino}
</Text>
<Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text> <Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text>
<View style={styles.dateRow}> <View style={styles.dateRow}>
<FontAwesome name="calendar" size={12} color="#4A6592" /> <FontAwesome name="calendar" size={12} color="#4A6592" />

View File

@@ -1,15 +1,15 @@
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth'; import { useAuth } from '@/assets/contexts/useAuth';
import ChevronRightIcon from '@/assets/icons/chevron-right.svg'; import ChevronRightIcon from '@/assets/icons/chevron-right.svg';
import UserDeleteIcon from '@/assets/icons/user-xmark-solid-full.svg';
import LogoutIcon from '@/assets/icons/right-from-bracket-solid-full.svg'; import LogoutIcon from '@/assets/icons/right-from-bracket-solid-full.svg';
import UserIcon from '@/assets/icons/user-solid-full.svg'; import UserIcon from '@/assets/icons/user-solid-full.svg';
import UserDeleteIcon from '@/assets/icons/user-xmark-solid-full.svg';
import { colors } from '@/assets/styles/colors'; import { colors } from '@/assets/styles/colors';
import { UserInfoResponse } from '@/assets/types'; import { UserInfoResponse } from '@/assets/types';
import styles from '@/styles/screens/tabs/perfil.styles'; import styles from '@/styles/screens/tabs/perfil.styles';
import { type Href, router } from 'expo-router'; import { type Href, router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { import {
Alert, Alert,
Image, Image,

View File

@@ -15,6 +15,8 @@ import { ValorReservaCard } from '@/assets/components/reserva/ValorReservaCard';
import { VooCard } from '@/assets/components/reserva/VooCard'; import { VooCard } from '@/assets/components/reserva/VooCard';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth'; import { useAuth } from '@/assets/contexts/useAuth';
import WifiIcon from '@/assets/icons/wifi-solid.svg';
import { colors } from '@/assets/styles/colors';
import { cacheReservaFull, getCachedReservaFull } from '@/assets/services/offlineStorage'; import { cacheReservaFull, getCachedReservaFull } from '@/assets/services/offlineStorage';
import { import {
Documento, Documento,
@@ -27,7 +29,6 @@ import {
VooSegment, VooSegment,
} from '@/assets/types'; } from '@/assets/types';
import styles from '@/styles/screens/reserva/detail.styles'; import styles from '@/styles/screens/reserva/detail.styles';
import { FontAwesome } from '@expo/vector-icons';
import { Stack, useLocalSearchParams } from 'expo-router'; import { Stack, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
@@ -118,6 +119,21 @@ export default function ReservaDetalheScreen() {
} }
}; };
function metodosPagamento($metodo: string) {
switch ($metodo) {
case 'mb':
return 'Multibanco';
case 'mbway':
return 'MB Way';
case 'cc':
return 'Cartão de Crédito';
case 'tb':
return 'Transferência Bancária';
default:
return $metodo;
}
}
useEffect(() => { useEffect(() => {
fetchReserva(); fetchReserva();
}, [referencia, token]); }, [referencia, token]);
@@ -196,7 +212,7 @@ export default function ReservaDetalheScreen() {
<> <>
{isFromCache && ( {isFromCache && (
<View style={styles.offlineBanner}> <View style={styles.offlineBanner}>
<FontAwesome name="wifi" size={13} color="#fff" /> <WifiIcon width={13} height={13} fill={colors.branco} />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text> <Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View> </View>
)} )}
@@ -350,7 +366,7 @@ export default function ReservaDetalheScreen() {
</Text> </Text>
</View> </View>
<View style={styles.tableCellRight}> <View style={styles.tableCellRight}>
<Text style={styles.tableValueText}>{pagamento.metodoPagamento}</Text> <Text style={styles.tableValueText}>{metodosPagamento(pagamento.metodoPagamento)}</Text>
</View> </View>
</View> </View>
<DashedDivider width={1} borderStyle="dashed" /> <DashedDivider width={1} borderStyle="dashed" />

View File

@@ -0,0 +1,76 @@
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { colors } from '@/assets/styles/colors';
import styles from '@/styles/screens/tabs/contactos.styles';
import { FontAwesome } from '@expo/vector-icons';
import Constants, { ExecutionEnvironment } from 'expo-constants';
import { lazy, Suspense, useMemo, useState } from 'react';
import { Image, Platform, Pressable, Text, View } from 'react-native';
const MAP_ZOOM = 15;
const isExpoGo = Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
const useNativeMaps = !isExpoGo && (Platform.OS === 'ios' || Platform.OS === 'android');
const NativeMapPreview = useNativeMaps
? lazy(() => import('./ContactMapPreviewNative'))
: null;
const buildStaticMapUrl = (lat: number, lng: number): string => {
const delta = 0.012;
const bbox = [lng - delta, lat - delta, lng + delta, lat + delta].join(',');
return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/export?bbox=${bbox}&bboxSR=4326&size=600,300&format=png&f=image`;
};
type Props = {
lat: number;
lng: number;
title?: string;
onPress: () => void;
};
function StaticMapFallback({ lat, lng, onPress }: Props) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const mapUrl = useMemo(() => buildStaticMapUrl(lat, lng), [lat, lng]);
return (
<Pressable onPress={onPress} style={styles.mapImageWrap}>
<Image
source={{ uri: mapUrl }}
style={styles.mapImage}
resizeMode="cover"
onLoadStart={() => {
setLoading(true);
setError(false);
}}
onLoad={() => setLoading(false)}
onError={() => {
setLoading(false);
setError(true);
}}
/>
{loading && !error && (
<View style={styles.mapOverlay}>
<LoadingSpinner size="small" />
</View>
)}
{error && (
<View style={styles.mapOverlay}>
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
</View>
)}
</Pressable>
);
}
export function ContactMapPreview(props: Props) {
if (NativeMapPreview) {
return (
<Suspense fallback={<StaticMapFallback {...props} />}>
<NativeMapPreview {...props} />
</Suspense>
);
}
return <StaticMapFallback {...props} />;
}

View File

@@ -0,0 +1,81 @@
import { colors } from '@/assets/styles/colors';
import styles from '@/styles/screens/tabs/contactos.styles';
import { AppleMaps, GoogleMaps } from 'expo-maps';
import { useMemo } from 'react';
import { Platform, Pressable, View } from 'react-native';
const MAP_ZOOM = 15;
const disabledGoogleUi = {
scrollGesturesEnabled: false,
zoomGesturesEnabled: false,
rotationGesturesEnabled: false,
tiltGesturesEnabled: false,
zoomControlsEnabled: false,
mapToolbarEnabled: false,
compassEnabled: false,
scaleBarEnabled: false,
};
const disabledAppleUi = {
compassEnabled: false,
scaleBarEnabled: false,
myLocationButtonEnabled: false,
togglePitchEnabled: false,
};
type Props = {
lat: number;
lng: number;
title?: string;
onPress: () => void;
};
export default function ContactMapPreviewNative({ lat, lng, title, onPress }: Props) {
const cameraPosition = useMemo(
() => ({
coordinates: { latitude: lat, longitude: lng },
zoom: MAP_ZOOM,
}),
[lat, lng]
);
const marker = useMemo(
() => ({
id: 'agency',
coordinates: { latitude: lat, longitude: lng },
title,
}),
[lat, lng, title]
);
return (
<Pressable onPress={onPress} style={styles.mapImageWrap}>
<View style={styles.mapImage} pointerEvents="none">
{Platform.OS === 'ios' ? (
<AppleMaps.View
style={styles.mapImage}
cameraPosition={cameraPosition}
markers={[
{
...marker,
systemImage: 'mappin',
tintColor: colors.vermelho,
},
]}
properties={{ selectionEnabled: false }}
uiSettings={disabledAppleUi}
/>
) : (
<GoogleMaps.View
style={styles.mapImage}
cameraPosition={cameraPosition}
markers={[marker]}
properties={{ selectionEnabled: false }}
uiSettings={disabledGoogleUi}
/>
)}
</View>
</Pressable>
);
}

View File

@@ -1,8 +1,8 @@
import { useAuth } from '@/assets/contexts/useAuth'; import { useAuth } from '@/assets/contexts/useAuth';
import { getCachedContacts } from '@/assets/services/offlineStorage'; import { getCachedContacts } from '@/assets/services/offlineStorage';
import PhoneIcon from '@/assets/icons/phone-solid.svg';
import { colors } from '@/assets/styles/colors'; import { colors } from '@/assets/styles/colors';
import { fonts } from '@/assets/styles/fonts'; import { fonts } from '@/assets/styles/fonts';
import { FontAwesome } from '@expo/vector-icons';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Linking, Platform, StyleSheet, Text, useWindowDimensions } from 'react-native'; import { Alert, Linking, Platform, StyleSheet, Text, useWindowDimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
@@ -27,7 +27,7 @@ export function EmergencyButton() {
const tabBarHeight = (Platform.OS === 'ios' ? 54 : 64) + insets.bottom; const tabBarHeight = (Platform.OS === 'ios' ? 54 : 64) + insets.bottom;
const maxX = screenWidth - BTN_SIZE - MARGIN; const maxX = screenWidth - BTN_SIZE - MARGIN;
const minY = insets.top + MARGIN; const minY = insets.top + MARGIN;
const maxY = screenHeight - tabBarHeight - BTN_SIZE - MARGIN; const maxY = screenHeight - tabBarHeight - BTN_SIZE + MARGIN;
const x = useSharedValue(screenWidth - BTN_SIZE - MARGIN); const x = useSharedValue(screenWidth - BTN_SIZE - MARGIN);
const y = useSharedValue(screenHeight - tabBarHeight - BTN_SIZE - MARGIN); const y = useSharedValue(screenHeight - tabBarHeight - BTN_SIZE - MARGIN);
@@ -49,7 +49,7 @@ export function EmergencyButton() {
if (!emergencyPhone) return; if (!emergencyPhone) return;
const url = `tel:${emergencyPhone.replace(/\s/g, '')}`; const url = `tel:${emergencyPhone.replace(/\s/g, '')}`;
Alert.alert( Alert.alert(
'Linha de Emergência 24h', 'Ajuda ao viajante 24h',
`Ligar para ${emergencyPhone}?`, `Ligar para ${emergencyPhone}?`,
[ [
{ text: 'Cancelar', style: 'cancel' }, { text: 'Cancelar', style: 'cancel' },
@@ -116,8 +116,8 @@ export function EmergencyButton() {
return ( return (
<GestureDetector gesture={composed}> <GestureDetector gesture={composed}>
<Animated.View style={[styles.btn, animatedStyle]}> <Animated.View style={[styles.btn, animatedStyle]}>
<FontAwesome name="phone" size={18} color={colors.branco} /> <PhoneIcon width={18} height={18} fill={colors.branco} />
<Text style={styles.label}>SOS</Text> <Text style={styles.label}>Ajuda</Text>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
); );

View File

@@ -6,8 +6,8 @@
*/ */
// Endpoint base da API // Endpoint base da API
export const API_BASE_URL = "https://apmtests.webclientes.com/pt/app"; export const API_BASE_URL = "https://cruiselovers.webclientes.com/pt/app";
export const API_BASE_URL_DEV = "https://apmtests.webclientes.com/pt/app"; export const API_BASE_URL_DEV = "https://cruiselovers.webclientes.com/pt/app";
/** /**
* Endpoints da aplicação * Endpoints da aplicação
@@ -47,7 +47,6 @@ export const API_ENDPOINTS = {
* *
* @example * @example
* buildApiUrl(API_ENDPOINTS.USER_LOGIN) * buildApiUrl(API_ENDPOINTS.USER_LOGIN)
* // Retorna: "https://finalguru.webclientes.com/pt/app/UserLogin"
*/ */
export function buildApiUrl(endpoint: string): string { export function buildApiUrl(endpoint: string): string {
// Remove barras duplicadas e garante que há apenas uma barra entre base e endpoint // Remove barras duplicadas e garante que há apenas uma barra entre base e endpoint

BIN
assets/icons/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,4 @@
<svg width="31" height="31" viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15.4507" cy="15.4507" r="15.4507" fill="#123572"/>
<path d="M14.1065 8.21875V10.75H17.4815V8.21875C17.4815 8.07812 17.3409 7.9375 17.2003 7.9375H14.3878C14.212 7.9375 14.1065 8.07812 14.1065 8.21875ZM20.294 10.75C21.5245 10.75 22.544 11.7695 22.544 13V20.875C22.544 22.1406 21.5245 23.125 20.294 23.125C20.294 23.7578 19.7667 24.25 19.169 24.25C18.5362 24.25 18.044 23.7578 18.044 23.125H13.544C13.544 23.7578 13.0167 24.25 12.419 24.25C11.7862 24.25 11.294 23.7578 11.294 23.125C10.0284 23.125 9.04401 22.1406 9.04401 20.875V13C9.04401 11.7695 10.0284 10.75 11.294 10.75H12.419V8.21875C12.419 7.16406 13.2979 6.25 14.3878 6.25H17.2003C18.2549 6.25 19.169 7.16406 19.169 8.21875V10.75H20.294ZM12.9815 14.125C12.6651 14.125 12.419 14.4062 12.419 14.6875C12.419 15.0039 12.6651 15.25 12.9815 15.25H18.6065C18.8878 15.25 19.169 15.0039 19.169 14.6875C19.169 14.4062 18.8878 14.125 18.6065 14.125H12.9815ZM12.9815 18.625C12.6651 18.625 12.419 18.9062 12.419 19.1875C12.419 19.5039 12.6651 19.75 12.9815 19.75H18.6065C18.8878 19.75 19.169 19.5039 19.169 19.1875C19.169 18.9062 18.8878 18.625 18.6065 18.625H12.9815Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="31" height="31" viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15.4507" cy="15.4507" r="15.4507" fill="white"/>
<path d="M10.544 10.9375C10.544 10.2266 11.1182 9.625 11.8565 9.625H19.7315C20.4424 9.625 21.044 10.2266 21.044 10.9375V19.25H10.544V10.9375ZM19.1026 12.2227C18.8292 11.9766 18.419 11.9766 18.1729 12.2227L15.1378 15.2578L13.8526 13.9727C13.5792 13.7266 13.169 13.7266 12.9229 13.9727C12.6495 14.2461 12.6495 14.6562 12.9229 14.9023L14.6729 16.6523C14.919 16.9258 15.3292 16.9258 15.6026 16.6523L19.1026 13.1523C19.3487 12.9062 19.3487 12.4961 19.1026 12.25V12.2227ZM7.91901 17.9375C7.91901 17.2266 8.49323 16.625 9.23151 16.625H9.66901V20.125H21.919V16.625H22.3565C23.0674 16.625 23.669 17.2266 23.669 17.9375V20.5625C23.669 21.3008 23.0674 21.875 22.3565 21.875H9.23151C8.49323 21.875 7.91901 21.3008 7.91901 20.5625V17.9375Z" fill="#123572"/>
</svg>

After

Width:  |  Height:  |  Size: 909 B

View File

@@ -0,0 +1,4 @@
<svg width="31" height="31" viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15.4507" cy="15.4507" r="15.4507" fill="white"/>
<path d="M13.169 8.75C13.6339 8.75 14.044 9.16016 14.044 9.625V10.5H17.544V9.625C17.544 9.16016 17.9268 8.75 18.419 8.75C18.8839 8.75 19.294 9.16016 19.294 9.625V10.5H20.6065C21.3174 10.5 21.919 11.1016 21.919 11.8125V13.125H9.66901V11.8125C9.66901 11.1016 10.2432 10.5 10.9815 10.5H12.294V9.625C12.294 9.16016 12.6768 8.75 13.169 8.75ZM9.66901 14H21.919V21.4375C21.919 22.1758 21.3174 22.75 20.6065 22.75H10.9815C10.2432 22.75 9.66901 22.1758 9.66901 21.4375V14ZM11.419 16.1875V17.0625C11.419 17.3086 11.6104 17.5 11.8565 17.5H12.7315C12.9503 17.5 13.169 17.3086 13.169 17.0625V16.1875C13.169 15.9688 12.9503 15.75 12.7315 15.75H11.8565C11.6104 15.75 11.419 15.9688 11.419 16.1875ZM14.919 16.1875V17.0625C14.919 17.3086 15.1104 17.5 15.3565 17.5H16.2315C16.4503 17.5 16.669 17.3086 16.669 17.0625V16.1875C16.669 15.9688 16.4503 15.75 16.2315 15.75H15.3565C15.1104 15.75 14.919 15.9688 14.919 16.1875ZM18.8565 15.75C18.6104 15.75 18.419 15.9688 18.419 16.1875V17.0625C18.419 17.3086 18.6104 17.5 18.8565 17.5H19.7315C19.9503 17.5 20.169 17.3086 20.169 17.0625V16.1875C20.169 15.9688 19.9503 15.75 19.7315 15.75H18.8565ZM11.419 19.6875V20.5625C11.419 20.8086 11.6104 21 11.8565 21H12.7315C12.9503 21 13.169 20.8086 13.169 20.5625V19.6875C13.169 19.4688 12.9503 19.25 12.7315 19.25H11.8565C11.6104 19.25 11.419 19.4688 11.419 19.6875ZM15.3565 19.25C15.1104 19.25 14.919 19.4688 14.919 19.6875V20.5625C14.919 20.8086 15.1104 21 15.3565 21H16.2315C16.4503 21 16.669 20.8086 16.669 20.5625V19.6875C16.669 19.4688 16.4503 19.25 16.2315 19.25H15.3565ZM18.419 19.6875V20.5625C18.419 20.8086 18.6104 21 18.8565 21H19.7315C19.9503 21 20.169 20.8086 20.169 20.5625V19.6875C20.169 19.4688 19.9503 19.25 19.7315 19.25H18.8565C18.6104 19.25 18.419 19.4688 18.419 19.6875Z" fill="#123572"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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="M164.9 24.6c-7.7-18.6-28-28.5-47.4-23.2l-88 24C12.1 30.2 0 46 0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c5.3-19.4-4.6-39.7-23.2-47.4l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96z"/></svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M54.2 202.9C123.2 136.7 216.8 96 320 96s196.8 40.7 265.8 106.9c12.8 12.2 33 11.8 45.2-.9s11.8-33-.9-45.2C549.7 79.5 440.4 32 320 32S90.3 79.5 9.8 156.7C-2.9 169-3.3 189.2 8.9 202s32.5 13.2 45.2 .9zM320 256c56.8 0 108.6 21.1 148.2 56c13.3 11.7 33.5 10.4 45.2-2.8s10.4-33.5-2.8-45.2C459.8 219.2 393 192 320 192s-139.8 27.2-190.5 72c-13.3 11.7-14.5 31.9-2.8 45.2s31.9 14.5 45.2 2.8c39.5-34.9 91.3-56 148.2-56zm64 160a64 64 0 1 0 -128 0 64 64 0 1 0 128 0z"/></svg>

After

Width:  |  Height:  |  Size: 682 B

25
eas.json Normal file
View File

@@ -0,0 +1,25 @@
{
"cli": {
"version": ">= 16.31.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"companyName": "Famatour - Viagens e Turismo, Lda"
}
}
}
}

1469
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
@@ -16,18 +16,20 @@
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"babel-preset-expo": "~54.0.10",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"expo": "~54.0.33", "expo": "~54.0.35",
"expo-clipboard": "^56.0.3", "expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-file-system": "^56.0.7", "expo-dev-client": "~6.0.21",
"expo-font": "~14.0.11", "expo-file-system": "~19.0.23",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11", "expo-image": "~3.0.11",
"expo-linking": "~8.0.12", "expo-linking": "~8.0.12",
"expo-maps": "^56.0.6", "expo-maps": "~0.12.10",
"expo-router": "~6.0.23", "expo-router": "~6.0.24",
"expo-sharing": "^56.0.13", "expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8", "expo-symbols": "~1.0.8",
@@ -42,7 +44,7 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-webview": "^13.16.1", "react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -36,7 +36,7 @@ export default StyleSheet.create({
}, },
title: { title: {
color: colors.branco, color: colors.branco,
fontSize: 36, fontSize: 32,
fontFamily: fonts.bold, fontFamily: fonts.bold,
marginBottom: 6, marginBottom: 6,
}, },

View File

@@ -22,8 +22,8 @@ export default StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
logo: { logo: {
width: 150, width: 180,
height: 34, height: 60,
}, },
centered: { centered: {
flex: 1, flex: 1,

View File

@@ -56,6 +56,9 @@ export default StyleSheet.create({
height: 38, height: 38,
marginBottom: 5, marginBottom: 5,
}, },
statIconSvg: {
marginBottom: 5,
},
statLabel: { statLabel: {
fontSize: 14, fontSize: 14,
color: colors.azul, color: colors.azul,
@@ -64,10 +67,11 @@ export default StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
statValue: { statValue: {
fontSize: 24, fontSize: 18,
color: colors.azul, color: colors.azul,
fontFamily: fonts.medium, fontFamily: fonts.medium,
lineHeight: 32, lineHeight: 24,
textAlign: 'center',
}, },
statLabelPrimary: { statLabelPrimary: {
fontSize: 14, fontSize: 14,
@@ -77,10 +81,11 @@ export default StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
statValuePrimary: { statValuePrimary: {
fontSize: 24, fontSize: 18,
color: colors.branco, color: colors.branco,
fontFamily: fonts.medium, fontFamily: fonts.medium,
lineHeight: 32, lineHeight: 24,
textAlign: 'center',
}, },
sectionTitle: { sectionTitle: {
fontSize: 24, fontSize: 24,
@@ -165,9 +170,11 @@ export default StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
gap: 4, gap: 4,
flexShrink: 1, flexShrink: 1,
minWidth: 0,
}, },
statusText: { statusText: {
flexShrink: 1, flexShrink: 1,
minWidth: 0,
fontSize: 12, fontSize: 12,
fontFamily: fonts.medium, fontFamily: fonts.medium,
textAlign: 'center', textAlign: 'center',