First commit of the new app
This commit is contained in:
@@ -1,35 +1,145 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Image, Platform, StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
export default function TabsLayout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
headerShown: true,
|
||||
headerTitleAlign: 'center',
|
||||
headerShadowVisible: false,
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background_1,
|
||||
},
|
||||
headerTitle: () => (
|
||||
<Image source={require('@/assets/icons/logotipo-azul.png')} style={styles.logo} resizeMode="contain" />
|
||||
),
|
||||
tabBarStyle: [
|
||||
styles.tabBar,
|
||||
{
|
||||
height: (Platform.OS === 'ios' ? 54 : 64) + insets.bottom,
|
||||
paddingBottom: 8 + insets.bottom,
|
||||
},
|
||||
],
|
||||
tabBarItemStyle: styles.tabItem,
|
||||
tabBarActiveTintColor: colors.vermelho,
|
||||
tabBarInactiveTintColor: colors.azul,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabBarHideOnKeyboard: true,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
name="perfil/index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
title: 'Perfil',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||
<Image
|
||||
source={
|
||||
focused
|
||||
? require('@/assets/icons/perfil-selecionado.png')
|
||||
: require('@/assets/icons/perfil.png')
|
||||
}
|
||||
style={focused ? styles.iconActive : styles.icon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
name="home/index"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
title: 'Inicio',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||
<Image
|
||||
source={
|
||||
focused
|
||||
? require('@/assets/icons/home-selecionado.png')
|
||||
: require('@/assets/icons/home.png')
|
||||
}
|
||||
style={focused ? styles.iconActive : styles.icon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="contactos/index"
|
||||
options={{
|
||||
title: 'Contactos',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<View style={focused ? styles.iconActiveContainer : styles.iconContainer}>
|
||||
<Image
|
||||
source={
|
||||
focused
|
||||
? require('@/assets/icons/contactos-fill.png')
|
||||
: require('@/assets/icons/contactos.png')
|
||||
}
|
||||
style={focused ? styles.iconActive : styles.icon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
paddingHorizontal: 50,
|
||||
paddingTop: 16,
|
||||
backgroundColor: colors.branco,
|
||||
borderTopWidth: 0,
|
||||
borderTopLeftRadius: 60,
|
||||
borderTopRightRadius: 60,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
elevation: 10,
|
||||
},
|
||||
tabItem: {
|
||||
paddingVertical: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
iconActiveContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.vermelho,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
icon: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
},
|
||||
iconActive: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
tintColor: colors.branco,
|
||||
},
|
||||
logo: {
|
||||
width: 180,
|
||||
height: 60,
|
||||
},
|
||||
});
|
||||
337
app/(tabs)/contactos/index.tsx
Normal file
337
app/(tabs)/contactos/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||
import { useAuth } from '@/assets/contexts/useAuth';
|
||||
import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { ContactData, ContactResponse, SocialMedia } from '@/assets/types';
|
||||
import styles from '@/styles/screens/tabs/contactos.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const openPhone = (phone: string) => {
|
||||
const clean = phone.replace(/[^\d+]/g, '');
|
||||
Linking.openURL(`tel:${clean}`).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir a aplicação de telefone.')
|
||||
);
|
||||
};
|
||||
|
||||
const openEmail = (email: string) => {
|
||||
Linking.openURL(`mailto:${email}`).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir a aplicação de e-mail.')
|
||||
);
|
||||
};
|
||||
|
||||
const openWhatsApp = (value: string) => {
|
||||
const clean = value.replace(/[^\d+]/g, '');
|
||||
const url = `https://wa.me/${clean}`;
|
||||
Linking.openURL(url).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir o WhatsApp.')
|
||||
);
|
||||
};
|
||||
|
||||
const openMaps = (address: string, lat?: number, lng?: number) => {
|
||||
const url =
|
||||
lat != null && lng != null
|
||||
? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(address)}`
|
||||
: `maps:?q=${encodeURIComponent(address)}`;
|
||||
|
||||
Linking.openURL(url).catch(() =>
|
||||
Linking.openURL(
|
||||
lat != null && lng != null
|
||||
? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
|
||||
).catch(() => Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'))
|
||||
);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (value: string, label: string) => {
|
||||
await Clipboard.setStringAsync(value);
|
||||
Alert.alert('Copiado', `${label} copiado para a área de transferência.`);
|
||||
};
|
||||
|
||||
const extractPhone = (url: string): string => {
|
||||
const match = url.match(/(?:phone=|wa\.me\/)([\d+]+)/);
|
||||
if (match) return `+${match[1].replace(/^\+/, '')}`;
|
||||
const clean = url.replace(/[^\d+]/g, '');
|
||||
return clean.startsWith('+') ? clean : `+${clean}`;
|
||||
};
|
||||
|
||||
const parseCoords = (raw?: string | null): { lat: number; lng: number } | null => {
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(',').map((s) => parseFloat(s.trim()));
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
return { lat: parts[0], lng: parts[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const parseHorarios = (raw: string): { dia: string; horas: string }[] => {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const sep = line.lastIndexOf(':');
|
||||
if (sep > 0) {
|
||||
return { dia: line.slice(0, sep).trim(), horas: line.slice(sep + 1).trim() };
|
||||
}
|
||||
const parts = line.split(/\s{2,}|\t/);
|
||||
if (parts.length >= 2) {
|
||||
return { dia: parts[0].trim(), horas: parts.slice(1).join(' ').trim() };
|
||||
}
|
||||
return { dia: line, horas: '' };
|
||||
});
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, string> = {
|
||||
facebook: 'facebook',
|
||||
instagram: 'instagram',
|
||||
tiktok: 'music', // FontAwesome doesn't have tiktok
|
||||
twitter: 'twitter',
|
||||
linkedin: 'linkedin',
|
||||
youtube: 'youtube-play',
|
||||
};
|
||||
|
||||
// ─── screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Contactos() {
|
||||
const { token } = useAuth();
|
||||
const [contactData, setContactData] = useState<ContactData | null>(null);
|
||||
const [socials, setSocials] = useState<SocialMedia[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mapLoading, setMapLoading] = useState(true);
|
||||
const [mapError, setMapError] = useState(false);
|
||||
|
||||
const fetchContacts = async () => {
|
||||
// 1. Mostrar cache imediatamente enquanto atualiza em fundo
|
||||
const cached = await getCachedContacts();
|
||||
if (cached.contactData) {
|
||||
setContactData(cached.contactData);
|
||||
setSocials(cached.socials);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (token) formData.append('token', token);
|
||||
|
||||
const response = await fetch(buildApiUrl(API_ENDPOINTS.CONTACTS), {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data: ContactResponse = await response.json();
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
if (data.status === 200 && data.contact) {
|
||||
const validSocials = (data.socials || []).filter((s) => s.value?.trim());
|
||||
setContactData(data.contact);
|
||||
setSocials(validSocials);
|
||||
setError(null);
|
||||
await cacheContacts(data.contact, validSocials);
|
||||
} else if (!cached.contactData) {
|
||||
setError(data.message || 'Não foi possível carregar os contactos.');
|
||||
}
|
||||
} catch {
|
||||
if (!cached.contactData) {
|
||||
setError('Sem ligação à internet e sem dados guardados.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [token]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchContacts();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !contactData) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : [];
|
||||
const coords = parseCoords(contactData?.coordenadas);
|
||||
const mapUrl = coords
|
||||
? `https://staticmap.openstreetmap.de/staticmap.php?center=${coords.lat},${coords.lng}&zoom=15&size=600x300&markers=${coords.lat},${coords.lng},red-pushpin`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
|
||||
<Text style={styles.pageTitle}>Contactos Agência</Text>
|
||||
|
||||
{/* ── Ações rápidas ── */}
|
||||
<View style={styles.actionsRow}>
|
||||
{!!contactData?.mobilePhone && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openPhone(contactData.telephone)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/telefone.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Ligar Agora</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{contactData.telephone}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{!!contactData?.email && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openEmail(contactData.email)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/email.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Enviar Email</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{contactData.email}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{!!contactData?.mobilePhone && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openWhatsApp(contactData.mobilePhone!)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/whatsapp.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Falar no Chat</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{extractPhone(contactData.mobilePhone)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Localização ── */}
|
||||
{!!contactData?.address && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Localização</Text>
|
||||
|
||||
{!!mapUrl && (
|
||||
<Pressable
|
||||
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}
|
||||
style={styles.mapImageWrap}>
|
||||
<Image
|
||||
source={{ uri: mapUrl }}
|
||||
style={styles.mapImage}
|
||||
resizeMode="cover"
|
||||
onLoadStart={() => { setMapLoading(true); setMapError(false); }}
|
||||
onLoad={() => setMapLoading(false)}
|
||||
onError={() => { setMapLoading(false); setMapError(true); }}
|
||||
/>
|
||||
{mapLoading && !mapError && (
|
||||
<View style={styles.mapOverlay}>
|
||||
<LoadingSpinner size="small" />
|
||||
</View>
|
||||
)}
|
||||
{mapError && (
|
||||
<View style={styles.mapOverlay}>
|
||||
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
|
||||
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={styles.addressRow}>
|
||||
<FontAwesome name="map-marker" size={16} color={colors.vermelho} />
|
||||
<Text style={styles.addressText}>{contactData.address}</Text>
|
||||
<Pressable onPress={() => copyToClipboard(contactData.address, 'Morada')}>
|
||||
<FontAwesome name="copy" size={14} color={colors.azul} style={styles.copyIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.direcoeBtn}
|
||||
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}>
|
||||
<Text style={styles.direcoeBtnText}>Obter Direções</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Horário de Funcionamento ── */}
|
||||
{horarios.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Horário de Funcionamento</Text>
|
||||
{horarios.map((item, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[styles.horarioRow, idx < horarios.length - 1 && styles.horarioRowBorder]}>
|
||||
<Text style={styles.horarioDia}>{item.dia}</Text>
|
||||
<Text style={[
|
||||
styles.horarioHoras,
|
||||
item.horas.toLowerCase() === 'fechado' && styles.horarioFechado,
|
||||
]}>
|
||||
{item.horas || '—'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Redes Sociais ── */}
|
||||
{socials.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Redes Sociais</Text>
|
||||
<View style={styles.socialsRow}>
|
||||
{socials.map((social, idx) => (
|
||||
<Pressable
|
||||
key={idx}
|
||||
style={styles.socialBtn}
|
||||
onPress={() => Linking.openURL(social.value).catch(() => null)}>
|
||||
<FontAwesome
|
||||
name={(SOCIAL_ICONS[social.key.toLowerCase()] ?? 'globe') as any}
|
||||
size={20}
|
||||
color={colors.azul}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
333
app/(tabs)/home/index.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
370
app/(tabs)/perfil/index.tsx
Normal file
370
app/(tabs)/perfil/index.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||
import { useAuth } from '@/assets/contexts/useAuth';
|
||||
import ChevronRightIcon from '@/assets/icons/chevron-right.svg';
|
||||
import UserDeleteIcon from '@/assets/icons/user-xmark-solid-full.svg';
|
||||
import LogoutIcon from '@/assets/icons/right-from-bracket-solid-full.svg';
|
||||
import UserIcon from '@/assets/icons/user-solid-full.svg';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { UserInfoResponse } from '@/assets/types';
|
||||
import styles from '@/styles/screens/tabs/perfil.styles';
|
||||
import { type Href, router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const ANIM_DURATION = 280;
|
||||
|
||||
export default function Perfil() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { token, logout, updateProfile, isLoading } = useAuth();
|
||||
const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isEditingOpen, setIsEditingOpen] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState<null | 'logout' | 'delete'>(null);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const contentHeight = useSharedValue(0);
|
||||
const [measuredHeight, setMeasuredHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (measuredHeight > 0) {
|
||||
contentHeight.value = measuredHeight;
|
||||
}
|
||||
}, [measuredHeight, contentHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(isEditingOpen ? 1 : 0, {
|
||||
duration: ANIM_DURATION,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}, [isEditingOpen, progress]);
|
||||
|
||||
const editBodyAnimatedStyle = useAnimatedStyle(() => ({
|
||||
height: contentHeight.value * progress.value,
|
||||
opacity: progress.value,
|
||||
}));
|
||||
|
||||
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${progress.value * 90}deg` }],
|
||||
}));
|
||||
|
||||
const getUserInfo = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('token', token);
|
||||
const response = await fetch(buildApiUrl(API_ENDPOINTS.USER_INFO), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data: UserInfoResponse = await response.json();
|
||||
console.log('data', data);
|
||||
setUserInfo(data);
|
||||
} catch {
|
||||
Alert.alert('Erro', 'Nao foi possivel carregar os dados do perfil.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo();
|
||||
}, [token]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await getUserInfo();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (newPassword || confirmPassword) {
|
||||
if (!newPassword || !confirmPassword) {
|
||||
Alert.alert('Erro', 'Preenche os dois campos de palavra-passe.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
Alert.alert('Erro', 'As palavras-passe nao coincidem.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateProfile(
|
||||
userInfo?.user?.nome,
|
||||
userInfo?.user?.apelido,
|
||||
undefined,
|
||||
newPassword || undefined,
|
||||
);
|
||||
Alert.alert('Sucesso', 'Perfil atualizado com sucesso.');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setIsEditingOpen(false);
|
||||
await getUserInfo();
|
||||
} catch (err) {
|
||||
Alert.alert('Erro', err instanceof Error ? err.message : 'Nao foi possivel atualizar o perfil.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setConfirmModal('logout');
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
setConfirmModal('delete');
|
||||
};
|
||||
|
||||
const confirmDeleteAccount = () => {
|
||||
Alert.alert(
|
||||
'Confirmacao final',
|
||||
'Todos os dados serao removidos permanentemente. Confirmar eliminacao?',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Eliminar',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (!token) return;
|
||||
setIsDeletingAccount(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('token', token);
|
||||
const response = await fetch(buildApiUrl(API_ENDPOINTS.DELETE_ACCOUNT), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data?.status === 200) {
|
||||
await logout();
|
||||
router.replace('/login' as Href);
|
||||
Alert.alert('Conta eliminada', 'A sua conta foi eliminada com sucesso.');
|
||||
} else {
|
||||
Alert.alert('Erro', data?.message || 'Nao foi possivel eliminar a conta.');
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('Erro', 'Falha ao contactar o servidor.');
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmModalAction = async () => {
|
||||
if (confirmModal === 'logout') {
|
||||
try {
|
||||
await logout();
|
||||
setConfirmModal(null);
|
||||
router.replace('/login' as Href);
|
||||
} catch {
|
||||
Alert.alert('Erro', 'Nao foi possivel terminar sessao.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmModal === 'delete') {
|
||||
setConfirmModal(null);
|
||||
confirmDeleteAccount();
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardVerticalOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={keyboardVerticalOffset}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
|
||||
<Text style={styles.title}>O meu Perfil</Text>
|
||||
|
||||
<View style={styles.userCard}>
|
||||
<Text style={styles.userName}>{userInfo?.user?.nome + ' ' + userInfo?.user?.apelido || 'Utilizador'}</Text>
|
||||
<Text style={styles.userEmail}>{userInfo?.user?.email || 'Utilizador'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuCard}>
|
||||
<Pressable style={styles.menuRow} onPress={() => setIsEditingOpen((prev) => !prev)}>
|
||||
<View style={styles.menuLeft}>
|
||||
<UserIcon width={24} height={24} fill={colors.azul} />
|
||||
<Text style={styles.menuText}>Editar Perfil</Text>
|
||||
</View>
|
||||
<Animated.View style={chevronAnimatedStyle}>
|
||||
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.editBodyWrap, editBodyAnimatedStyle]}
|
||||
pointerEvents={isEditingOpen ? 'auto' : 'none'}>
|
||||
<View
|
||||
style={styles.editBodyMeasure}
|
||||
onLayout={(e) => {
|
||||
const h = Math.ceil(e.nativeEvent.layout.height);
|
||||
if (h > 0) setMeasuredHeight(h);
|
||||
}}>
|
||||
<View style={styles.accordionContent}>
|
||||
<View style={styles.rowInputs}>
|
||||
<View style={styles.inputBlock}>
|
||||
<Text style={styles.inputLabel}>Nome<Text style={styles.required}></Text></Text>
|
||||
<TextInput
|
||||
value={userInfo?.user?.nome || ''}
|
||||
onChangeText={(text) =>
|
||||
setUserInfo((prev) =>
|
||||
prev?.user ? { ...prev, user: { ...prev.user, nome: text } } : prev,
|
||||
)
|
||||
}
|
||||
style={styles.input}
|
||||
placeholder="Nome"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputBlock}>
|
||||
<Text style={styles.inputLabel}>Apelido<Text style={styles.required}></Text></Text>
|
||||
<TextInput
|
||||
value={userInfo?.user?.apelido || ''}
|
||||
onChangeText={(text) =>
|
||||
setUserInfo((prev) =>
|
||||
prev?.user ? { ...prev, user: { ...prev.user, apelido: text } } : prev,
|
||||
)
|
||||
}
|
||||
style={styles.input}
|
||||
placeholder="Apelido"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputBlockFull}>
|
||||
<Text style={styles.inputLabel}>Nova Palavra-passe<Text style={styles.required}></Text></Text>
|
||||
<TextInput
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
style={styles.input}
|
||||
placeholder="Palavra-passe"
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputBlockFull}>
|
||||
<Text style={styles.inputLabel}>Confirmar Nova Palavra-passe<Text style={styles.required}></Text></Text>
|
||||
<TextInput
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
style={styles.input}
|
||||
placeholder="Palavra-passe"
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.saveButton} onPress={handleSave} disabled={isSubmitting || isLoading}>
|
||||
{isSubmitting ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.saveButtonText}>Guardar Alteracoes</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||
</>
|
||||
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Pressable style={styles.menuRow} onPress={handleLogout}>
|
||||
<View style={styles.menuLeft}>
|
||||
<LogoutIcon width={24} height={24} fill={colors.azul} />
|
||||
<Text style={styles.menuText}>Log out</Text>
|
||||
</View>
|
||||
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Pressable style={styles.menuRow} onPress={handleDeleteAccount} disabled={isDeletingAccount}>
|
||||
<View style={styles.menuLeft}>
|
||||
<UserDeleteIcon width={24} height={24} fill={colors.azul} />
|
||||
<Text style={styles.menuText}>{isDeletingAccount ? 'A eliminar...' : 'Eliminar Conta'}</Text>
|
||||
</View>
|
||||
<ChevronRightIcon width={18} height={18} fill={colors.vermelho} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{confirmModal && (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={() => setConfirmModal(null)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalCard}>
|
||||
<View style={styles.modalIconWrap}>
|
||||
{confirmModal === 'delete' ? (
|
||||
<UserDeleteIcon width={24} height={24} fill={colors.branco} />
|
||||
) : (
|
||||
<LogoutIcon width={24} height={24} fill={colors.branco} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.modalTitle}>
|
||||
{confirmModal === 'delete' ? 'Eliminar Conta' : 'Fazer Log out'}
|
||||
</Text>
|
||||
<Text style={styles.modalSubtitle}>
|
||||
{confirmModal === 'delete'
|
||||
? 'Tens a certeza que queres eliminar a tua conta?'
|
||||
: 'Tens a certeza que queres sair da tua conta?'}
|
||||
</Text>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Pressable
|
||||
style={[styles.modalButton, styles.modalPrimaryButton]}
|
||||
onPress={confirmModalAction}
|
||||
disabled={isDeletingAccount}>
|
||||
<Text style={styles.modalPrimaryText}>
|
||||
{confirmModal === 'delete' ? (isDeletingAccount ? 'A eliminar...' : 'Eliminar') : 'Sair'}
|
||||
</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.modalButton, styles.modalSecondaryButton]}
|
||||
onPress={() => setConfirmModal(null)}>
|
||||
<Text style={styles.modalSecondaryText}>Cancelar</Text>
|
||||
<Image source={require('@/assets/icons/seta-up-fill.png')} style={styles.arrowUpButtonIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user