394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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, useMemo, 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;
|
|
|
|
const formatDisplayName = (nome?: string, apelido?: string) => {
|
|
const parts = [nome?.trim(), apelido?.trim()].filter((p): p is string => !!p);
|
|
return parts.length > 0 ? parts.join(' ') : null;
|
|
};
|
|
|
|
export default function Perfil() {
|
|
const insets = useSafeAreaInsets();
|
|
const { token, user: authUser, logout, updateProfile, isLoading } = useAuth();
|
|
const [userInfo, setUserInfo] = useState<UserInfoResponse | null>(null);
|
|
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
|
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) {
|
|
setUserInfo(null);
|
|
setIsLoadingProfile(false);
|
|
return;
|
|
}
|
|
setIsLoadingProfile(true);
|
|
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();
|
|
setUserInfo(data);
|
|
} catch {
|
|
Alert.alert('Erro', 'Nao foi possivel carregar os dados do perfil.');
|
|
} finally {
|
|
setIsLoadingProfile(false);
|
|
}
|
|
};
|
|
|
|
const displayName = useMemo(() => {
|
|
const fromApi = formatDisplayName(userInfo?.user?.nome, userInfo?.user?.apelido);
|
|
if (fromApi) return fromApi;
|
|
return formatDisplayName(authUser?.nome, authUser?.apelido);
|
|
}, [userInfo, authUser]);
|
|
|
|
const displayEmail = userInfo?.user?.email?.trim() || authUser?.email?.trim() || null;
|
|
|
|
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}>
|
|
{isLoadingProfile && !displayName ? (
|
|
<LoadingSpinner size="small" />
|
|
) : (
|
|
<Text style={styles.userName}>{displayName || 'Utilizador'}</Text>
|
|
)}
|
|
<Text style={styles.userEmail}>{displayEmail || '—'}</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>
|
|
);
|
|
} |