First commit of the new app

This commit is contained in:
2026-05-26 09:18:37 +01:00
parent 295d1bda21
commit b427fb0f85
110 changed files with 6483 additions and 833 deletions

370
app/(tabs)/perfil/index.tsx Normal file
View 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>
);
}