First commit of the new app
This commit is contained in:
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