dados perfil aluno

This commit is contained in:
2026-04-28 17:16:03 +01:00
parent 0b544677a8
commit 6a9a16e4ea
24 changed files with 514 additions and 174 deletions

View File

@@ -10,10 +10,10 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.teu-nome.estagiospap"
"bundleIdentifier": "com.epvc.estagiospap"
},
"android": {
"package": "com.teu_nome.estagiospap",
"package": "com.epvc.estagiospap",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",

View File

@@ -22,8 +22,8 @@ import {
View
} from 'react-native';
import { Calendar, LocaleConfig } from 'react-native-calendars';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
// Configuração PT-PT para o Calendário
LocaleConfig.locales['pt'] = {

View File

@@ -14,8 +14,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
const Definicoes = memo(() => {
const router = useRouter();

View File

@@ -1,9 +1,9 @@
// app/Aluno/PerfilAluno.tsx
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
KeyboardAvoidingView,
Platform,
@@ -16,60 +16,102 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
export default function PerfilAluno() {
const { isDarkMode } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
// --- ESTADOS ---
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [perfil, setPerfil] = useState<any>(null);
const [estagio, setEstagio] = useState<any>(null);
const [saving, setSaving] = useState(false);
// --- ANIMAÇÕES ---
const [alertConfig, setAlertConfig] = useState<{ msg: string, type: 'success' | 'error' | 'info' } | null>(null);
const alertOpacity = useMemo(() => new Animated.Value(0), []);
const fadeAnim = useMemo(() => new Animated.Value(0), []);
const [faltasJustificadas, setFaltasJustificadas] = useState(0);
const [faltasInjustificadas, setFaltasInjustificadas] = useState(0);
const azulPetroleo = '#2390a6';
const fadeAnim = useMemo(() => new Animated.Value(0), []);
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: azulPetroleo,
azul: '#2390a6',
azulSuave: isDarkMode ? 'rgba(35, 144, 166, 0.15)' : 'rgba(35, 144, 166, 0.1)',
vermelhoSuave: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)',
vermelho: '#EF4444',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
verde: '#10B981',
sombra: isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.05)',
}), [isDarkMode]);
const showAlert = useCallback((msg: string, type: 'success' | 'error' | 'info' = 'info') => {
setAlertConfig({ msg, type });
Animated.sequence([
Animated.timing(alertOpacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.delay(3000),
Animated.timing(alertOpacity, { toValue: 0, duration: 400, useNativeDriver: true })
]).start(() => setAlertConfig(null));
}, []);
// --- LÓGICA DE VERIFICAÇÃO DO ESTÁGIO ATIVO ---
const isEstagioAtivo = useMemo(() => {
if (!estagio) return false; // Se não tem estágio na DB, não está ativo
if (!estagio.data_fim) return true; // Se tem estágio mas sem data de fim, assumimos ativo
const hoje = new Date();
hoje.setHours(0, 0, 0, 0); // Reset às horas para comparar só o dia
const dataFim = new Date(estagio.data_fim);
dataFim.setHours(0, 0, 0, 0);
return dataFim >= hoje; // Se a data de fim for hoje ou no futuro, está ativo
}, [estagio]);
// --- FUNÇÕES DE DATA ---
const formatarDataParaUI = (dataDB: string) => {
if (!dataDB) return '';
const parts = dataDB.split('-');
if (parts.length !== 3) return dataDB;
return `${parts[2]}-${parts[1]}-${parts[0]}`;
};
const formatarDataParaDB = (dataUI: string) => {
if (!dataUI || dataUI.length !== 10) return null;
const parts = dataUI.split('-');
if (parts.length !== 3) return null;
return `${parts[2]}-${parts[1]}-${parts[0]}`;
};
const calcularIdade = (dataPT: string) => {
if (!dataPT || dataPT.length !== 10) return null;
const parts = dataPT.split('-');
const dia = parseInt(parts[0], 10);
const mes = parseInt(parts[1], 10);
const ano = parseInt(parts[2], 10);
if (ano < 1950 || ano > new Date().getFullYear()) return null;
const hoje = new Date();
const nascimento = new Date(ano, mes - 1, dia);
if (nascimento.getDate() !== dia) return null;
let idade = hoje.getFullYear() - nascimento.getFullYear();
const m = hoje.getMonth() - nascimento.getMonth();
if (m < 0 || (m === 0 && hoje.getDate() < nascimento.getDate())) {
idade--;
}
return idade >= 0 ? idade : 0;
};
const aplicarMascaraData = (text: string) => {
const cleaned = text.replace(/\D/g, '');
let formatted = cleaned;
if (cleaned.length > 2) formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2)}`;
if (cleaned.length > 4) formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2, 4)}-${cleaned.slice(4, 8)}`;
if (cleaned.length > 2 && cleaned.length <= 4) {
formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2)}`;
} else if (cleaned.length > 4) {
formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2, 4)}-${cleaned.slice(4, 8)}`;
}
return formatted;
};
// --- CARREGAMENTO DE DADOS COM JOIN DE HORÁRIOS ---
// --- FIM FUNÇÕES DATA ---
const buscarDados = async () => {
try {
setLoading(true);
@@ -84,23 +126,35 @@ export default function PerfilAluno() {
if (alunoRes) aData = alunoRes;
}
// Join com a tabela horarios_estagio
const { data: eData } = await supabase
.from('estagios')
.select(`
*,
empresas(nome, tutor_nome),
horarios_estagio(periodo, hora_inicio, hora_fim)
`)
.select(`*, empresas(*), horarios_estagio(*)`)
.eq('aluno_id', user.id)
.maybeSingle();
setPerfil({ ...pData, ...aData, email: user.email });
const { data: presencas } = await supabase.from('presencas').select('estado, justificacao_url').eq('aluno_id', user.id);
if (presencas) {
setFaltasJustificadas(presencas.filter(p => p.estado === 'Falta' && p.justificacao_url).length);
setFaltasInjustificadas(presencas.filter(p => p.estado === 'Falta' && !p.justificacao_url).length);
}
const dataFormatadaUI = formatarDataParaUI(pData?.data_nascimento);
const idadeCalculada = dataFormatadaUI ? calcularIdade(dataFormatadaUI) : pData?.idade;
setPerfil({
...aData,
...pData,
email: user.email,
data_nascimento: dataFormatadaUI,
idade: idadeCalculada ?? 'N/A'
});
setEstagio(eData);
Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }).start();
} catch (err) {
showAlert('Erro ao sincronizar dados.', 'error');
console.error(err);
} finally {
setLoading(false);
}
@@ -111,20 +165,30 @@ export default function PerfilAluno() {
const salvarDados = async () => {
try {
setSaving(true);
const { error } = await supabase.from('profiles').update({
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const dataProntaParaDB = formatarDataParaDB(perfil.data_nascimento);
const { error, data } = await supabase.from('profiles').update({
nome: perfil.nome,
telefone: perfil.telefone,
residencia: perfil.residencia,
idade: perfil.idade,
data_nascimento: perfil.data_nascimento
}).eq('id', perfil.id);
data_nascimento: dataProntaParaDB,
idade: perfil.idade !== 'N/A' ? Number(perfil.idade) : null,
telefone: perfil.telefone
}).eq('id', user.id).select();
if (error) throw error;
if (!data || data.length === 0) {
throw new Error("Erro de RLS. Confirma as tuas políticas no Supabase.");
}
setIsEditing(false);
showAlert('Perfil guardado!', 'success');
buscarDados();
} catch (e) {
showAlert('Falha ao guardar.', 'error');
Alert.alert("Sucesso", "Perfil atualizado!");
await buscarDados();
} catch (e: any) {
Alert.alert("Erro ao gravar", e.message);
} finally {
setSaving(false);
}
@@ -135,15 +199,8 @@ export default function PerfilAluno() {
return (
<KeyboardAvoidingView style={{ flex: 1, backgroundColor: cores.fundo }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
{alertConfig && (
<Animated.View style={[styles.alert, { opacity: alertOpacity, backgroundColor: alertConfig.type === 'error' ? cores.vermelho : cores.verde, top: insets.top + 10 }]}>
<Ionicons name="information-circle" size={20} color="#fff" />
<Text style={styles.alertText}>{alertConfig.msg}</Text>
</Animated.View>
)}
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={styles.headerContainer}>
<TouchableOpacity style={[styles.roundBtn, { backgroundColor: cores.card }]} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={22} color={cores.texto} />
@@ -161,86 +218,115 @@ export default function PerfilAluno() {
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
<Animated.View style={{ opacity: fadeAnim }}>
{/* CABEÇALHO */}
<View style={styles.avatarSection}>
<View style={[styles.avatarFrame, { borderColor: cores.azulSuave }]}>
<View style={[styles.avatarCircle, { backgroundColor: cores.azul }]}>
<Text style={styles.avatarInitial}>{perfil?.nome?.charAt(0).toUpperCase()}</Text>
</View>
<View style={[styles.avatarCircle, { backgroundColor: cores.azul }]}>
<Text style={styles.avatarInitial}>{perfil?.nome?.charAt(0).toUpperCase()}</Text>
</View>
<Text style={[styles.profileName, { color: cores.texto }]}>{perfil?.nome}</Text>
<TextInput
style={[styles.profileName, { color: cores.texto, borderBottomWidth: isEditing ? 1 : 0, borderBottomColor: cores.borda }]}
value={perfil?.nome}
editable={isEditing}
onChangeText={(v) => setPerfil({...perfil, nome: v})}
placeholder="Nome do Aluno"
placeholderTextColor={cores.secundario}
/>
<View style={[styles.badge, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.badgeText, { color: cores.azul }]}>{perfil?.tipo?.toUpperCase()}</Text>
<Text style={[styles.badgeText, { color: cores.azul }]}>{perfil?.turma_curso || 'CURSO NÃO DEFINIDO'}</Text>
</View>
</View>
{/* DADOS ESCOLARES */}
<Text style={[styles.sectionHeader, { color: cores.secundario }]}>Registo Escolar</Text>
<View style={[styles.infoCard, { backgroundColor: cores.card, shadowColor: cores.sombra }]}>
<View style={[styles.infoCard, { backgroundColor: cores.card }]}>
<View style={styles.inputRow}>
<View style={{ flex: 1, marginRight: 12 }}><PerfilInput label="Nº Escola" icon="id-card-outline" value={perfil?.n_escola?.toString()} editable={false} cores={cores} /></View>
<View style={{ flex: 1 }}><PerfilInput label="Ano" icon="calendar-outline" value={perfil?.ano?.toString() + 'º Ano'} editable={false} cores={cores} /></View>
</View>
<PerfilInput label="Turma e Curso" icon="school-outline" value={perfil?.turma_curso} editable={false} cores={cores} />
</View>
{/* DADOS PESSOAIS */}
<Text style={[styles.sectionHeader, { color: cores.secundario }]}>Contactos e Pessoal</Text>
<View style={[styles.infoCard, { backgroundColor: cores.card, shadowColor: cores.sombra }]}>
<PerfilInput label="Email" icon="mail-outline" value={perfil?.email} editable={false} cores={cores} />
<View style={styles.inputRow}>
<View style={{ flex: 0.8, marginRight: 12 }}><PerfilInput label="Idade" icon="time-outline" value={perfil?.idade?.toString()} editable={isEditing} onChangeText={(v:string) => setPerfil({...perfil, idade: v})} cores={cores} keyboardType="numeric" /></View>
<View style={{ flex: 1.2 }}><PerfilInput label="Telemóvel" icon="call-outline" value={perfil?.telefone} editable={isEditing} onChangeText={(v:string) => setPerfil({...perfil, telefone: v})} cores={cores} keyboardType="phone-pad" maxLength={9} /></View>
</View>
<PerfilInput label="Nascimento" icon="gift-outline" value={perfil?.data_nascimento} editable={isEditing} onChangeText={(v:string) => setPerfil({...perfil, data_nascimento: aplicarMascaraData(v)})} cores={cores} placeholder="DD-MM-AAAA" />
</View>
{/* SECÇÃO ESTÁGIO COM LOGICA DE HORARIOS RELACIONADOS */}
{estagio && (
<>
<Text style={[styles.sectionHeader, { color: cores.secundario }]}>Informação de Estágio</Text>
<View style={[styles.infoCard, { backgroundColor: cores.card, borderLeftWidth: 4, borderLeftColor: cores.azul, shadowColor: cores.sombra }]}>
<PerfilInput label="Empresa" icon="business-outline" value={estagio.empresas?.nome} editable={false} cores={cores} />
<View style={styles.inputRow}>
<View style={{ flex: 1, marginRight: 12 }}>
<PerfilInput label="Horas" icon="timer-outline" value={estagio.horas_totais?.toString() + 'h'} editable={false} cores={cores} />
</View>
<View style={{ flex: 2 }}>
<PerfilInput
label="Horário"
icon="watch-outline"
value={
estagio.horarios_estagio?.length > 0
? estagio.horarios_estagio.map((h: any) =>
`${h.periodo}: ${h.hora_inicio.slice(0,5)}-${h.hora_fim.slice(0,5)}`
).join('\n')
: "Não definido"
}
editable={false}
cores={cores}
multiline={true}
/>
</View>
</View>
{estagio.empresas?.tutor_nome && <PerfilInput label="Tutor" icon="person-circle-outline" value={estagio.empresas.tutor_nome} editable={false} cores={cores} />}
<View style={{ flex: 1, marginRight: 10 }}>
<PerfilInput label="Nº Escola" icon="id-card-outline" value={perfil?.n_escola?.toString()} editable={false} cores={cores} />
</View>
</>
<View style={{ flex: 1 }}>
<PerfilInput label="Ano" icon="calendar-outline" value={perfil?.ano ? `${perfil.ano}º Ano` : 'N/A'} editable={false} cores={cores} />
</View>
</View>
<PerfilInput label="Email Institucional" icon="mail-outline" value={perfil?.email} editable={false} cores={cores} />
</View>
<Text style={[styles.sectionHeader, { color: cores.secundario }]}>Dados Pessoais</Text>
<View style={[styles.infoCard, { backgroundColor: cores.card }]}>
<PerfilInput
label="Data Nascimento" icon="gift-outline"
value={perfil?.data_nascimento}
editable={isEditing}
onChangeText={(v:string) => {
const masked = aplicarMascaraData(v);
const novaIdade = calcularIdade(masked);
setPerfil({...perfil, data_nascimento: masked, idade: novaIdade ?? 'N/A'});
}}
cores={cores}
placeholder="DD-MM-AAAA"
maxLength={10}
/>
<View style={styles.inputRow}>
<View style={{ flex: 1, marginRight: 10 }}>
<PerfilInput label="Idade" icon="time-outline" value={perfil?.idade?.toString()} editable={false} cores={cores} />
</View>
<View style={{ flex: 2 }}>
<PerfilInput label="Telemóvel" icon="call-outline" value={perfil?.telefone} editable={isEditing} onChangeText={(v:string) => setPerfil({...perfil, telefone: v})} cores={cores} keyboardType="phone-pad" />
</View>
</View>
<PerfilInput label="Residência" icon="location-outline" value={perfil?.residencia} editable={isEditing} onChangeText={(v:string) => setPerfil({...perfil, residencia: v})} cores={cores} />
</View>
<Text style={[styles.sectionHeader, { color: cores.secundario }]}>Informação de Estágio</Text>
{/* Aqui entra a condição: Só mostra a info se isEstagioAtivo for verdadeiro */}
{isEstagioAtivo ? (
<View style={[styles.infoCard, { backgroundColor: cores.card, borderLeftWidth: 4, borderLeftColor: cores.azul }]}>
<PerfilInput label="Entidade de Acolhimento" icon="business-outline" value={estagio.empresas?.nome} editable={false} cores={cores} />
<View style={styles.inputRow}>
<View style={{ flex: 1, marginRight: 10 }}>
<PerfilInput label="Horas Totais" icon="timer-outline" value={`${estagio.horas_totais}h`} editable={false} cores={cores} />
</View>
<View style={{ flex: 1 }}>
<PerfilInput label="Realizadas" icon="checkmark-done-outline" value={`${estagio.horas_concluidas || 0}h`} editable={false} cores={cores} />
</View>
</View>
<View style={[styles.inputRow, { marginTop: 10 }]}>
<View style={[styles.miniStatus, { backgroundColor: cores.azulSuave, flex: 1, marginRight: 10 }]}>
<Text style={[styles.miniStatusText, { color: cores.azul }]}>JUSTIFICADAS: {faltasJustificadas}</Text>
</View>
<View style={[styles.miniStatus, { backgroundColor: cores.vermelhoSuave, flex: 1 }]}>
<Text style={[styles.miniStatusText, { color: cores.vermelho }]}>INJUSTIFICADAS: {faltasInjustificadas}</Text>
</View>
</View>
</View>
) : (
<View style={[styles.infoCard, { backgroundColor: cores.card, alignItems: 'center', padding: 30, borderStyle: 'dashed', borderWidth: 1, borderColor: cores.borda }]}>
<Ionicons name="warning-outline" size={30} color={cores.secundario} />
{/* Mensagem dinâmica se já teve ou se nunca teve estágio */}
<Text style={{ color: cores.secundario, marginTop: 10, fontWeight: '700', textAlign: 'center' }}>
{estagio ? "Sem estágio ativo no momento." : "Sem estágio atribuído no sistema"}
</Text>
</View>
)}
{/* ACÇÕES */}
<View style={styles.footer}>
<TouchableOpacity style={[styles.actionMenuItem, { backgroundColor: cores.card }]} onPress={() => router.push('/Aluno/redefenirsenha')}>
<View style={[styles.actionIcon, { backgroundColor: cores.azulSuave }]}><Ionicons name="key-outline" size={20} color={cores.azul} /></View>
<Text style={[styles.actionText, { color: cores.texto }]}>Redefinir Senha</Text>
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
<TouchableOpacity style={[styles.actionMenuItem, { backgroundColor: cores.card }]} onPress={() => router.push('/redefenirsenha')}>
<View style={[styles.actionIcon, { backgroundColor: cores.azulSuave }]}>
<Ionicons name="lock-closed-outline" size={20} color={cores.azul} />
</View>
<Text style={[styles.actionText, { color: cores.texto }]}>Alterar palavra-passe</Text>
<Ionicons name="chevron-forward" size={18} color={cores.borda} />
</TouchableOpacity>
<TouchableOpacity style={[styles.actionMenuItem, { backgroundColor: cores.card, marginTop: 12, marginBottom: 40 }]} onPress={() => supabase.auth.signOut().then(() => router.replace('/'))}>
<View style={[styles.actionIcon, { backgroundColor: cores.vermelhoSuave }]}><Ionicons name="log-out-outline" size={20} color={cores.vermelho} /></View>
<TouchableOpacity
style={[styles.actionMenuItem, { backgroundColor: cores.card, marginTop: 12, marginBottom: 50 }]}
onPress={() => supabase.auth.signOut().then(() => router.replace('/'))}
>
<View style={[styles.actionIcon, { backgroundColor: cores.vermelhoSuave }]}>
<Ionicons name="log-out-outline" size={20} color={cores.vermelho} />
</View>
<Text style={[styles.actionText, { color: cores.vermelho }]}>Terminar Sessão</Text>
</TouchableOpacity>
</View>
</Animated.View>
</ScrollView>
</SafeAreaView>
@@ -248,23 +334,15 @@ export default function PerfilAluno() {
);
}
const PerfilInput = ({ label, icon, cores, editable, multiline, ...props }: any) => (
const PerfilInput = ({ label, icon, cores, editable, ...props }: any) => (
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { color: cores.secundario }]}>{label}</Text>
<View style={[styles.inputContainer, {
backgroundColor: cores.fundo,
borderColor: editable ? cores.azul : cores.borda,
height: multiline ? undefined : 52,
minHeight: 52,
paddingVertical: multiline ? 8 : 0
}]}>
<Ionicons name={icon} size={18} color={cores.azul} style={{ marginHorizontal: 12, marginTop: multiline ? 10 : 0 }} />
<View style={[styles.inputContainer, { backgroundColor: cores.fundo, borderColor: editable ? cores.azul : cores.borda, borderStyle: editable ? 'solid' : 'dashed' }]}>
<Ionicons name={icon} size={18} color={cores.azul} style={{ marginHorizontal: 12 }} />
<TextInput
{...props}
editable={editable}
multiline={multiline}
scrollEnabled={false}
style={[styles.textInput, { color: cores.texto, textAlignVertical: multiline ? 'top' : 'center' }]}
style={[styles.textInput, { color: cores.texto }]}
placeholderTextColor={cores.secundario}
/>
</View>
@@ -275,26 +353,25 @@ const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
headerContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingVertical: 15 },
headerTitle: { fontSize: 19, fontWeight: '900' },
roundBtn: { width: 48, height: 48, borderRadius: 16, justifyContent: 'center', alignItems: 'center', elevation: 2 },
scrollContainer: { paddingHorizontal: 20, paddingBottom: 20 },
avatarSection: { alignItems: 'center', marginVertical: 25 },
avatarFrame: { padding: 6, borderRadius: 100, borderWidth: 2, borderStyle: 'dashed' },
avatarCircle: { width: 95, height: 95, borderRadius: 48, alignItems: 'center', justifyContent: 'center', elevation: 5 },
roundBtn: { width: 45, height: 45, borderRadius: 14, justifyContent: 'center', alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
scrollContainer: { paddingHorizontal: 20 },
avatarSection: { alignItems: 'center', marginVertical: 20 },
avatarCircle: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8 },
avatarInitial: { color: '#fff', fontSize: 40, fontWeight: '800' },
profileName: { fontSize: 24, fontWeight: '900', marginTop: 15 },
badge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 8, marginTop: 8 },
badgeText: { fontSize: 11, fontWeight: '800', letterSpacing: 1 },
sectionHeader: { fontSize: 11, fontWeight: '900', textTransform: 'uppercase', letterSpacing: 1.5, marginLeft: 8, marginBottom: 12, marginTop: 15 },
infoCard: { borderRadius: 25, padding: 20, elevation: 3, shadowOpacity: 0.1 },
profileName: { fontSize: 24, fontWeight: '900', marginTop: 15, textAlign: 'center', width: '100%' },
badge: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 10, marginTop: 8 },
badgeText: { fontSize: 10, fontWeight: '900', letterSpacing: 1, textTransform: 'uppercase' },
sectionHeader: { fontSize: 11, fontWeight: '900', textTransform: 'uppercase', letterSpacing: 1.5, marginBottom: 12, marginTop: 25, marginLeft: 5 },
infoCard: { borderRadius: 24, padding: 20, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 10 },
inputGroup: { marginBottom: 15 },
inputLabel: { fontSize: 10, fontWeight: '800', textTransform: 'uppercase', marginBottom: 6, marginLeft: 4 },
inputContainer: { flexDirection: 'row', alignItems: 'center', borderRadius: 16, borderWidth: 1.5 },
textInput: { flex: 1, fontSize: 14, fontWeight: '700' },
inputLabel: { fontSize: 9, fontWeight: '900', textTransform: 'uppercase', marginBottom: 6, marginLeft: 4, letterSpacing: 0.5 },
inputContainer: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, borderWidth: 1.5, height: 52 },
textInput: { flex: 1, fontSize: 15, fontWeight: '700' },
inputRow: { flexDirection: 'row', justifyContent: 'space-between' },
footer: { marginTop: 20 },
actionMenuItem: { flexDirection: 'row', alignItems: 'center', padding: 16, borderRadius: 22, elevation: 2 },
actionIcon: { width: 42, height: 42, borderRadius: 14, justifyContent: 'center', alignItems: 'center' },
actionText: { flex: 1, marginLeft: 15, fontSize: 16, fontWeight: '800' },
alert: { position: 'absolute', left: 20, right: 20, padding: 18, borderRadius: 20, flexDirection: 'row', alignItems: 'center', zIndex: 999 },
alertText: { color: '#fff', fontWeight: '800', marginLeft: 10, flex: 1 }
miniStatus: { padding: 12, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
miniStatusText: { fontSize: 10, fontWeight: '900' },
footer: { marginTop: 25 },
actionMenuItem: { flexDirection: 'row', alignItems: 'center', padding: 16, borderRadius: 20, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 3 },
actionIcon: { width: 40, height: 40, borderRadius: 14, justifyContent: 'center', alignItems: 'center' },
actionText: { flex: 1, marginLeft: 15, fontSize: 15, fontWeight: '800' }
});

View File

@@ -16,8 +16,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
interface Presenca {

View File

@@ -15,8 +15,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// Função para calcular idade automaticamente
const calcularIdade = (data: string): string => {

View File

@@ -16,8 +16,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- UTILITÁRIOS ---
const calcularIdade = (dataNascimento: string) => {

View File

@@ -15,8 +15,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- Interfaces ---
interface Aluno { id: string; nome: string; turma_curso: string; ano: number; }

View File

@@ -18,8 +18,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
export interface Aluno {

View File

@@ -16,8 +16,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
export interface Aluno {

View File

@@ -14,8 +14,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
export interface Aluno {

View File

@@ -16,8 +16,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
export interface Aluno {

View File

@@ -16,8 +16,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
export interface Empresa {
id: string;

View File

@@ -19,8 +19,8 @@ import {
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../../lib/supabase';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
export interface Empresa {
id: number;

View File

@@ -14,8 +14,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
interface PerfilData {
id: string;

View File

@@ -13,8 +13,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
const { width } = Dimensions.get('window');

View File

@@ -14,8 +14,8 @@ import {
View
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
const Definicoes = memo(() => {
const router = useRouter();

View File

@@ -14,7 +14,7 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { supabase } from '../../app/lib/supabase';
import { supabase } from '../../lib/supabase';
export default function ForgotPassword() {
const [email, setEmail] = useState('');

View File

@@ -1,17 +1,47 @@
// app/_layout.tsx
import { NavigationContainer } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
// CORREÇÃO: O caminho mudou porque moveste a pasta lib para a raiz
import { supabase } from '../lib/supabase';
import { ThemeProvider, useTheme } from '../themecontext';
function RootLayoutContent() {
const { isDarkMode } = useTheme();
const router = useRouter();
useEffect(() => {
// Escuta mudanças no estado de autenticação
const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
console.log("EVENTO SUPABASE:", event);
if (event === 'PASSWORD_RECOVERY') {
console.log("A redirecionar para: /novapasse");
// Usamos um delay ligeiramente maior para garantir que o RootLayout
// já montou a pilha de navegação (Stack)
const timer = setTimeout(() => {
// Usamos replace para não permitir que o user volte para uma rota "quebrada"
router.replace('/novapasse');
}, 1000);
return () => clearTimeout(timer);
}
});
return () => {
authListener.subscription.unsubscribe();
};
}, []);
return (
<>
<StatusBar style={isDarkMode ? "light" : "dark"} />
<Stack screenOptions={{ headerShown: false }}>
{/* Removido o .tsx do name, o Expo Router usa apenas o nome do ficheiro */}
<Stack.Screen name="index" />
{/* O index é o teu login */}
<Stack.Screen name="index" />
{/* Garantimos que a rota novapasse existe na stack */}
<Stack.Screen name="novapasse" />
</Stack>
</>
);
@@ -23,10 +53,4 @@ export default function RootLayout() {
<RootLayoutContent />
</ThemeProvider>
);
}
<ThemeProvider>
<NavigationContainer children={undefined}>
{/* aluno e professor */}
</NavigationContainer>
</ThemeProvider>
}

View File

@@ -11,7 +11,7 @@ import {
View
} from 'react-native';
import Auth from '../components/Auth';
import { supabase } from './lib/supabase';
import { supabase } from '../lib/supabase';
export default function LoginScreen() {
const router = useRouter();

239
app/novapasse.tsx Normal file
View File

@@ -0,0 +1,239 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// CORREÇÃO 1: O caminho agora é ../lib/supabase porque moveste a pasta para a raiz
import { supabase } from '../lib/supabase';
// CORREÇÃO 2: O nome da função deve ser preferencialmente o nome do ficheiro
export default function NovaPasse() {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [status, setStatus] = useState<{ type: 'error' | 'success'; msg: string } | null>(null);
const router = useRouter();
const handleUpdatePassword = async () => {
setStatus(null);
if (password.length < 6) {
setStatus({ type: 'error', msg: 'A password deve ter pelo menos 6 caracteres.' });
return;
}
if (password !== confirmPassword) {
setStatus({ type: 'error', msg: 'As passwords não coincidem.' });
return;
}
setLoading(true);
try {
// O utilizador já está autenticado pelo link de recuperação
const { error } = await supabase.auth.updateUser({ password: password });
if (error) throw error;
setStatus({ type: 'success', msg: 'Sucesso! A tua password foi atualizada.' });
// Manda para o ecrã inicial (index) após 3 segundos
setTimeout(() => router.replace('/'), 3000);
} catch (err: any) {
setStatus({ type: 'error', msg: 'Erro ao atualizar. O link pode ter expirado.' });
} finally {
setLoading(false);
}
};
return (
<View style={styles.mainContainer}>
<StatusBar barStyle="dark-content" backgroundColor="#FFFFFF" />
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContainer}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
<View style={styles.header}>
<View style={styles.iconWrapper}>
<View style={styles.iconCircle}>
<Ionicons name="lock-open-outline" size={38} color="#2390a6" />
</View>
<View style={styles.iconBadge} />
</View>
<Text style={styles.title}>Nova Palavra-passe</Text>
<Text style={styles.subtitle}>
Cria uma nova senha segura para acederes à tua conta.
</Text>
</View>
{status && (
<View style={[styles.statusBanner, status.type === 'success' ? styles.successBg : styles.errorBg]}>
<Ionicons
name={status.type === 'success' ? "checkmark-circle" : "alert-circle"}
size={20}
color={status.type === 'success' ? "#059669" : "#EF4444"}
/>
<Text style={[styles.statusText, status.type === 'success' ? styles.successText : styles.errorText]}>
{status.msg}
</Text>
</View>
)}
<View style={styles.form}>
<View style={styles.inputWrapper}>
<Text style={[styles.label, isFocused && { color: '#2390a6' }]}>Nova Password</Text>
<TextInput
style={[styles.input, isFocused && styles.inputFocused]}
placeholder="Mínimo 6 caracteres"
placeholderTextColor="#94A3B8"
value={password}
onChangeText={setPassword}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
secureTextEntry
editable={!loading}
/>
</View>
<View style={styles.inputWrapper}>
<Text style={styles.label}>Confirmar Password</Text>
<TextInput
style={styles.input}
placeholder="Repita a password"
placeholderTextColor="#94A3B8"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!loading}
/>
</View>
<TouchableOpacity
activeOpacity={0.8}
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleUpdatePassword}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Atualizar Password</Text>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>Segurança EPVC Estágios+ 2026</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
mainContainer: { flex: 1, backgroundColor: '#FFFFFF' },
scrollContainer: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 80,
paddingBottom: 40,
},
content: { flex: 1, justifyContent: 'center' },
header: { alignItems: 'center', marginBottom: 35 },
iconWrapper: { position: 'relative', marginBottom: 20 },
iconCircle: {
width: 90,
height: 90,
backgroundColor: '#F0F7FF',
borderRadius: 30,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#DBEAFE',
},
iconBadge: {
position: 'absolute',
bottom: -4,
right: -4,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#2390a6',
borderWidth: 3,
borderColor: '#FFFFFF',
},
title: {
fontSize: 28,
fontWeight: '900',
color: '#0F172A',
textAlign: 'center',
letterSpacing: -1,
},
subtitle: {
fontSize: 15,
color: '#64748B',
textAlign: 'center',
marginTop: 10,
lineHeight: 22,
maxWidth: 300,
},
statusBanner: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginBottom: 25,
borderWidth: 1,
},
statusText: { fontSize: 14, fontWeight: '600', marginLeft: 10, flex: 1 },
errorBg: { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' },
errorText: { color: '#B91C1C' },
successBg: { backgroundColor: '#F0FDF4', borderColor: '#DCFCE7' },
successText: { color: '#166534' },
form: { width: '100%' },
inputWrapper: { marginBottom: 24 },
label: { fontSize: 14, fontWeight: '700', color: '#475569', marginBottom: 10, marginLeft: 4 },
input: {
backgroundColor: '#FBFDFF',
borderRadius: 18,
paddingHorizontal: 20,
paddingVertical: 18,
fontSize: 16,
color: '#0F172A',
borderWidth: 1.5,
borderColor: '#F1F5F9',
},
inputFocused: { borderColor: '#2390a6', backgroundColor: '#FFFFFF' },
button: {
backgroundColor: '#dd8707',
borderRadius: 18,
paddingVertical: 20,
alignItems: 'center',
elevation: 6,
},
buttonDisabled: { backgroundColor: '#E2E8F0', elevation: 0 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '800' },
footer: { marginTop: 40, alignItems: 'center' },
footerText: { fontSize: 12, color: '#CBD5E1', fontWeight: '600' },
});

View File

@@ -14,7 +14,7 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { supabase } from '../app/lib/supabase';
import { supabase } from '../lib/supabase';
export default function ForgotPassword() {
const [email, setEmail] = useState('');

View File

@@ -10,7 +10,7 @@ import {
TouchableOpacity,
View
} from 'react-native';
import { supabase } from '../app/lib/supabase';
import { supabase } from '../lib/supabase';
interface AuthProps {
onLoginSuccess?: () => void;