atualizacoes

This commit is contained in:
2026-03-13 17:20:44 +00:00
parent 7c5efa2695
commit 3404c0044d
12 changed files with 1523 additions and 841 deletions

View File

@@ -1,7 +1,7 @@
{
"expo": {
"name": "estagios_pap",
"slug": "estagios_pap",
"name": "Estágios PAP",
"slug": "estagios-pap",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
@@ -10,17 +10,16 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.estagios-pap"
"bundleIdentifier": "com.teu-nome.estagiospap"
},
"android": {
"package": "com.teu_nome.estagiospap",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
"backgroundImage": "./assets/images/android-icon-background.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"edgeToEdgeEnabled": true
},
"web": {
"output": "static",
@@ -46,4 +45,4 @@
"reactCompiler": true
}
}
}
}

View File

@@ -2,18 +2,20 @@ import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useFocusEffect } from '@react-navigation/native';
import * as DocumentPicker from 'expo-document-picker';
import * as Location from 'expo-location'; // <-- Adicionado
// ALTERADO: Importação da API Legacy para evitar o erro de depreciação
import { decode } from 'base64-arraybuffer';
import * as FileSystem from 'expo-file-system/legacy';
import * as Location from 'expo-location';
import { useRouter } from 'expo-router';
import * as Sharing from 'expo-sharing';
import { memo, useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator, Alert, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View
ActivityIndicator, Alert, Linking, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View
} from 'react-native';
import { Calendar, LocaleConfig } from 'react-native-calendars';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase'; // <-- Garante que tens este arquivo configurado
import { supabase } from '../lib/supabase';
// Configuração PT (Mantida)
// Configuração PT
LocaleConfig.locales['pt'] = {
monthNames: ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'],
monthNamesShort: ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'],
@@ -23,7 +25,7 @@ LocaleConfig.locales['pt'] = {
LocaleConfig.defaultLocale = 'pt';
const getFeriadosMap = (ano: number) => {
const f: Record<string, string> = {
return {
[`${ano}-01-01`]: "Ano Novo",
[`${ano}-04-25`]: "Dia da Liberdade",
[`${ano}-05-01`]: "Dia do Trabalhador",
@@ -36,7 +38,6 @@ const getFeriadosMap = (ano: number) => {
[`${ano}-12-08`]: "Imaculada Conceição",
[`${ano}-12-25`]: "Natal"
};
return f;
};
const AlunoHome = memo(() => {
@@ -46,42 +47,82 @@ const AlunoHome = memo(() => {
const [selectedDate, setSelectedDate] = useState(hojeStr);
const [configEstagio, setConfigEstagio] = useState({ inicio: '2026-01-05', fim: '2026-05-30' });
const [presencas, setPresencas] = useState<Record<string, boolean>>({});
const [faltas, setFaltas] = useState<Record<string, boolean>>({});
const [sumarios, setSumarios] = useState<Record<string, string>>({});
const [faltasJustificadas, setFaltasJustificadas] = useState<Record<string, any>>({});
const [urlsJustificacao, setUrlsJustificacao] = useState<Record<string, string>>({});
const [pdf, setPdf] = useState<any>(null);
const [editandoSumario, setEditandoSumario] = useState(false);
const [isLocating, setIsLocating] = useState(false); // Novo estado para loading
const [isLoadingDB, setIsLoadingDB] = useState(true);
const [isLocating, setIsLocating] = useState(false);
const [isUploading, setIsUploading] = useState(false);
useFocusEffect(
useCallback(() => {
const carregarTudo = async () => {
try {
const [config, pres, falt, sums, just] = await Promise.all([
AsyncStorage.getItem('@dados_estagio'),
AsyncStorage.getItem('@presencas'),
AsyncStorage.getItem('@faltas'),
AsyncStorage.getItem('@sumarios'),
AsyncStorage.getItem('@justificacoes')
]);
if (config) setConfigEstagio(JSON.parse(config));
if (pres) setPresencas(JSON.parse(pres));
if (falt) setFaltas(JSON.parse(falt));
if (sums) setSumarios(JSON.parse(sums));
if (just) setFaltasJustificadas(JSON.parse(just));
} catch (e) { console.error(e); }
};
carregarTudo();
carregarConfigLocal();
fetchDadosSupabase();
}, [])
);
const carregarConfigLocal = async () => {
try {
const config = await AsyncStorage.getItem('@dados_estagio');
if (config) setConfigEstagio(JSON.parse(config));
} catch (e) { console.error(e); }
};
const fetchDadosSupabase = async () => {
setIsLoadingDB(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('presencas')
.select('data, estado, sumario, justificacao_url')
.eq('aluno_id', user.id);
if (error) throw error;
const objPresencas: Record<string, boolean> = {};
const objFaltas: Record<string, boolean> = {};
const objSumarios: Record<string, string> = {};
const objUrls: Record<string, string> = {};
data?.forEach(item => {
if (item.estado === 'presente') {
objPresencas[item.data] = true;
if (item.sumario) objSumarios[item.data] = item.sumario;
} else if (item.estado === 'faltou') {
objFaltas[item.data] = true;
if (item.justificacao_url) objUrls[item.data] = item.justificacao_url;
}
});
setPresencas(objPresencas);
setFaltas(objFaltas);
setSumarios(objSumarios);
setUrlsJustificacao(objUrls);
} catch (error: any) {
console.error("Erro na BD:", error.message);
} finally {
setIsLoadingDB(false);
}
};
const themeStyles = useMemo(() => ({
fundo: isDarkMode ? '#121212' : '#f1f3f5',
card: isDarkMode ? '#1e1e1e' : '#fff',
texto: isDarkMode ? '#fff' : '#000',
textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d',
borda: isDarkMode ? '#333' : '#ddd',
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
textoSecundario: isDarkMode ? '#94A3B8' : '#64748B',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
azul: '#3B82F6',
vermelho: '#EF4444',
verde: '#10B981',
}), [isDarkMode]);
const feriadosMap = useMemo(() => getFeriadosMap(new Date(selectedDate).getFullYear()), [selectedDate]);
@@ -106,39 +147,33 @@ const AlunoHome = memo(() => {
const diasMarcados: any = useMemo(() => {
const marcacoes: any = {};
listaFeriados.forEach(d => { marcacoes[d] = { marked: true, dotColor: '#0dcaf0' }; });
listaFeriados.forEach(d => { marcacoes[d] = { marked: true, dotColor: '#3B82F6' }; });
Object.keys(presencas).forEach((d) => {
const temSumario = sumarios[d] && sumarios[d].trim().length > 0;
marcacoes[d] = { marked: true, dotColor: temSumario ? '#198754' : '#ffc107' };
marcacoes[d] = { marked: true, dotColor: temSumario ? '#10B981' : '#F59E0B' };
});
Object.keys(faltas).forEach((d) => {
marcacoes[d] = { marked: true, dotColor: faltasJustificadas[d] ? '#6c757d' : '#dc3545' };
});
marcacoes[selectedDate] = { ...(marcacoes[selectedDate] || {}), selected: true, selectedColor: '#0d6efd' };
return marcacoes;
}, [presencas, faltas, sumarios, faltasJustificadas, selectedDate, listaFeriados]);
// --- LOGICA DE PRESENÇA COM LOCALIZAÇÃO ---
const handlePresenca = async () => {
if (!infoData.podeMarcarPresenca) return Alert.alert("Bloqueado", "A presença só pode ser marcada no próprio dia.");
Object.keys(faltas).forEach((d) => {
marcacoes[d] = { marked: true, dotColor: urlsJustificacao[d] ? '#64748B' : '#EF4444' };
});
marcacoes[selectedDate] = { ...(marcacoes[selectedDate] || {}), selected: true, selectedColor: '#3B82F6' };
return marcacoes;
}, [presencas, faltas, sumarios, urlsJustificacao, selectedDate, listaFeriados]);
const handlePresenca = async () => {
if (!infoData.podeMarcarPresenca) return Alert.alert("Bloqueado", "Data inválida.");
setIsLocating(true);
try {
// 1. Pedir Permissão
let { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Erro', 'Precisas de aceitar a localização para marcar presença.');
setIsLocating(false);
return;
}
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') throw new Error("Permissão de localização necessária.");
// 2. Obter Localização exata
let location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
const location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
const { latitude, longitude } = location.coords;
// 3. Salvar no Supabase (Para o Professor ver)
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Usuário não autenticado.");
if (!user) throw new Error("Não autenticado.");
const { error } = await supabase.from('presencas').upsert({
aluno_id: user.id,
@@ -149,16 +184,10 @@ const AlunoHome = memo(() => {
});
if (error) throw error;
// 4. Salvar Localmente (Como estava no teu código)
const novas = { ...presencas, [selectedDate]: true };
setPresencas(novas);
await AsyncStorage.setItem('@presencas', JSON.stringify(novas));
Alert.alert("Sucesso", "Presença marcada com localização!");
} catch (e) {
console.error(e);
Alert.alert("Erro", "Não foi possível salvar a presença. Se estiveres sem net, vai dar merda.");
Alert.alert("Sucesso", "Presença registada!");
fetchDadosSupabase();
} catch (e: any) {
Alert.alert("Erro", "Falha ao registar presença.");
} finally {
setIsLocating(false);
}
@@ -166,33 +195,30 @@ const AlunoHome = memo(() => {
const handleFalta = async () => {
if (!infoData.valida) return Alert.alert("Bloqueado", "Data inválida.");
// Para que o professor veja a falta, também temos de mandar para o Supabase
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await supabase.from('presencas').upsert({
aluno_id: user.id,
data: selectedDate,
estado: 'faltou'
});
}
const novas = { ...faltas, [selectedDate]: true };
setFaltas(novas);
await AsyncStorage.setItem('@faltas', JSON.stringify(novas));
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { error } = await supabase.from('presencas').upsert({
aluno_id: user.id,
data: selectedDate,
estado: 'faltou'
});
if (error) throw error;
fetchDadosSupabase();
} catch (e) { Alert.alert("Erro", "Falha ao registar falta."); }
};
const guardarSumario = async () => {
// Enviar sumário para o Supabase também
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await supabase.from('presencas').update({ sumario: sumarios[selectedDate] })
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { error } = await supabase.from('presencas').update({ sumario: sumarios[selectedDate] })
.match({ aluno_id: user.id, data: selectedDate });
}
await AsyncStorage.setItem('@sumarios', JSON.stringify(sumarios));
setEditandoSumario(false);
Alert.alert("Sucesso", "Sumário guardado!");
if (error) throw error;
setEditandoSumario(false);
Alert.alert("Sucesso", "Sumário guardado!");
fetchDadosSupabase();
} catch (e) { Alert.alert("Erro", "Falha ao guardar sumário."); }
};
const escolherPDF = async () => {
@@ -201,21 +227,68 @@ const AlunoHome = memo(() => {
};
const enviarJustificacao = async () => {
if (!pdf) return Alert.alert('Aviso', 'Anexe um PDF.');
const novas = { ...faltasJustificadas, [selectedDate]: pdf };
setFaltasJustificadas(novas);
await AsyncStorage.setItem('@justificacoes', JSON.stringify(novas));
setPdf(null);
if (!pdf) return;
setIsUploading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Utilizador não autenticado");
const fileExt = pdf.name.split('.').pop();
const fileName = `${user.id}/${selectedDate}_${Date.now()}.${fileExt}`;
// SOLUÇÃO: Agora a API legacy já permite o readAsStringAsync sem avisos
const base64 = await FileSystem.readAsStringAsync(pdf.uri, {
encoding: 'base64'
});
const { error: uploadError } = await supabase.storage
.from('justificacoes')
.upload(fileName, decode(base64), {
contentType: 'application/pdf',
upsert: true
});
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from('justificacoes')
.getPublicUrl(fileName);
const { error: dbError } = await supabase
.from('presencas')
.update({ justificacao_url: publicUrl })
.match({ aluno_id: user.id, data: selectedDate });
if (dbError) throw dbError;
Alert.alert("Sucesso", "Justificação enviada!");
setPdf(null);
fetchDadosSupabase();
} catch (e: any) {
console.error(e);
Alert.alert("Erro", "Se a net falhar, vai dar merda: " + e.message);
} finally {
setIsUploading(false);
}
};
const visualizarDocumento = async (uri: string) => {
if (await Sharing.isAvailableAsync()) await Sharing.shareAsync(uri);
const visualizarDocumento = async (url: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
}
} catch (e) {
Alert.alert("Erro", "Não foi possível abrir o documento.");
}
};
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: themeStyles.fundo }]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<ScrollView contentContainerStyle={styles.container}>
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.push('/Aluno/perfil')}>
<Ionicons name="person-circle-outline" size={32} color={themeStyles.texto} />
@@ -228,14 +301,14 @@ const AlunoHome = memo(() => {
<View style={styles.botoesLinha}>
<TouchableOpacity
style={[styles.btn, styles.btnPresenca, (!infoData.podeMarcarPresenca || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
style={[styles.btn, { backgroundColor: themeStyles.azul }, (!infoData.podeMarcarPresenca || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
onPress={handlePresenca}
disabled={!infoData.podeMarcarPresenca || !!presencas[selectedDate] || !!faltas[selectedDate] || isLocating}
>
{isLocating ? <ActivityIndicator color="#fff" /> : <Text style={styles.txtBtn}>Marcar Presença</Text>}
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.btnFalta, (!infoData.valida || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
style={[styles.btn, { backgroundColor: themeStyles.vermelho }, (!infoData.valida || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
onPress={handleFalta}
disabled={!infoData.valida || !!presencas[selectedDate] || !!faltas[selectedDate]}
>
@@ -243,31 +316,41 @@ const AlunoHome = memo(() => {
</TouchableOpacity>
</View>
<View style={[styles.cardCalendar, { backgroundColor: themeStyles.card }]}>
<View style={[styles.cardCalendar, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
{isLoadingDB && (
<View style={styles.loaderOverlay}>
<ActivityIndicator size="large" color={themeStyles.azul} />
</View>
)}
<Calendar
key={isDarkMode ? 'dark' : 'light'}
theme={{ calendarBackground: themeStyles.card, dayTextColor: themeStyles.texto, monthTextColor: themeStyles.texto, todayTextColor: '#0d6efd', arrowColor: '#0d6efd' }}
theme={{
calendarBackground: themeStyles.card,
dayTextColor: themeStyles.texto,
monthTextColor: themeStyles.texto,
todayTextColor: themeStyles.azul,
arrowColor: themeStyles.azul,
selectedDayBackgroundColor: themeStyles.azul
}}
markedDates={diasMarcados}
onDayPress={(day) => { setSelectedDate(day.dateString); setEditandoSumario(false); }}
/>
</View>
{infoData.nomeFeriado && (
<View style={styles.cardFeriado}>
<Ionicons name="gift-outline" size={18} color="#0dcaf0" />
<Text style={styles.txtFeriado}>
{selectedDate.endsWith('-06-24') ? "É Feriado em Vila do Conde: São João" : `Feriado: ${infoData.nomeFeriado}`}
</Text>
<View style={[styles.cardFeriado, { backgroundColor: themeStyles.azul + '15' }]}>
<Ionicons name="gift-outline" size={18} color={themeStyles.azul} />
<Text style={[styles.txtFeriado, { color: themeStyles.azul }]}>{infoData.nomeFeriado}</Text>
</View>
)}
{presencas[selectedDate] && (
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<View style={styles.rowTitle}>
<Text style={[styles.cardTitulo, { color: themeStyles.texto }]}>Sumário do Dia</Text>
{!editandoSumario && (
<TouchableOpacity onPress={() => setEditandoSumario(true)}>
<Ionicons name="create-outline" size={22} color="#0d6efd" />
<Ionicons name="create-outline" size={22} color={themeStyles.azul} />
</TouchableOpacity>
)}
</View>
@@ -280,7 +363,7 @@ const AlunoHome = memo(() => {
placeholderTextColor={themeStyles.textoSecundario}
/>
{editandoSumario && (
<TouchableOpacity style={styles.btnGuardar} onPress={guardarSumario}>
<TouchableOpacity style={[styles.btnAcao, { backgroundColor: themeStyles.verde }]} onPress={guardarSumario}>
<Text style={styles.txtBtn}>Guardar Sumário</Text>
</TouchableOpacity>
)}
@@ -288,20 +371,24 @@ const AlunoHome = memo(() => {
)}
{faltas[selectedDate] && (
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<Text style={[styles.cardTitulo, { color: themeStyles.texto, marginBottom: 15 }]}>Justificação de Falta</Text>
{faltasJustificadas[selectedDate] ? (
<TouchableOpacity style={styles.btnVer} onPress={() => visualizarDocumento(faltasJustificadas[selectedDate].uri)}>
{urlsJustificacao[selectedDate] ? (
<TouchableOpacity style={[styles.btnVer, { backgroundColor: themeStyles.textoSecundario }]} onPress={() => visualizarDocumento(urlsJustificacao[selectedDate])}>
<Ionicons name="document-text-outline" size={20} color="#fff" />
<Text style={[styles.txtBtn, { marginLeft: 8 }]}>Ver Justificação (PDF)</Text>
</TouchableOpacity>
) : (
<View>
<TouchableOpacity style={[styles.btnAnexar, { borderColor: themeStyles.borda }]} onPress={escolherPDF}>
<Text style={{ color: themeStyles.texto }}>{pdf ? pdf.name : 'Anexar PDF'}</Text>
<Text style={{ color: themeStyles.texto }}>{pdf ? pdf.name : 'Selecionar PDF'}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btnGuardar, !pdf && styles.disabled]} onPress={enviarJustificacao} disabled={!pdf}>
<Text style={styles.txtBtn}>Enviar Justificação</Text>
<TouchableOpacity
style={[styles.btnAcao, { backgroundColor: themeStyles.verde }, (!pdf || isUploading) && styles.disabled]}
onPress={enviarJustificacao}
disabled={!pdf || isUploading}
>
{isUploading ? <ActivityIndicator color="#fff" /> : <Text style={styles.txtBtn}>Enviar para o Servidor</Text>}
</TouchableOpacity>
</View>
)}
@@ -314,25 +401,24 @@ const AlunoHome = memo(() => {
const styles = StyleSheet.create({
safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 },
container: { padding: 20 },
container: { padding: 20, paddingBottom: 40 },
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 },
title: { fontSize: 20, fontWeight: 'bold' },
title: { fontSize: 22, fontWeight: '800' },
botoesLinha: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
btn: { padding: 15, borderRadius: 12, width: '48%', alignItems: 'center' },
btnPresenca: { backgroundColor: '#0d6efd' },
btnFalta: { backgroundColor: '#dc3545' },
btnGuardar: { backgroundColor: '#198754', padding: 12, borderRadius: 10, marginTop: 10, alignItems: 'center' },
btnAnexar: { borderWidth: 1, padding: 12, borderRadius: 10, marginBottom: 10, alignItems: 'center', borderStyle: 'dashed' },
btnVer: { backgroundColor: '#6c757d', padding: 12, borderRadius: 10, flexDirection: 'row', justifyContent: 'center', alignItems: 'center' },
disabled: { opacity: 0.3 },
txtBtn: { color: '#fff', fontWeight: 'bold' },
cardCalendar: { borderRadius: 16, elevation: 4, padding: 10, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 10 },
cardFeriado: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 15, backgroundColor: 'rgba(13, 202, 240, 0.1)', padding: 10, borderRadius: 10 },
txtFeriado: { color: '#0dcaf0', fontWeight: 'bold', marginLeft: 8 },
card: { padding: 16, borderRadius: 16, marginTop: 20, elevation: 2 },
cardTitulo: { fontSize: 16, fontWeight: 'bold' },
rowTitle: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
input: { borderWidth: 1, borderRadius: 10, padding: 12, height: 100, textAlignVertical: 'top' }
btn: { padding: 16, borderRadius: 16, width: '48%', alignItems: 'center', elevation: 2 },
btnAcao: { padding: 14, borderRadius: 12, marginTop: 10, alignItems: 'center' },
btnAnexar: { borderWidth: 1, padding: 14, borderRadius: 12, marginBottom: 10, alignItems: 'center', borderStyle: 'dashed' },
btnVer: { padding: 14, borderRadius: 12, flexDirection: 'row', justifyContent: 'center', alignItems: 'center' },
disabled: { opacity: 0.4 },
txtBtn: { color: '#fff', fontWeight: 'bold', fontSize: 14 },
cardCalendar: { borderRadius: 24, padding: 10, borderWidth: 1, position: 'relative', overflow: 'hidden' },
loaderOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.4)', zIndex: 10 },
cardFeriado: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 15, padding: 12, borderRadius: 12 },
txtFeriado: { fontWeight: '700', marginLeft: 8 },
card: { padding: 20, borderRadius: 24, marginTop: 20, borderWidth: 1 },
cardTitulo: { fontSize: 17, fontWeight: '700' },
rowTitle: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
input: { borderWidth: 1, borderRadius: 12, padding: 15, height: 120, textAlignVertical: 'top', fontSize: 15 }
});
export default AlunoHome;

View File

@@ -1,6 +1,10 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
ActivityIndicator, Alert, Platform, SafeAreaView, ScrollView, StatusBar,
StyleSheet, Text, TextInput, TouchableOpacity, View
} from 'react-native';
import { useTheme } from '../../themecontext';
import { supabase } from '../lib/supabase';
@@ -8,8 +12,79 @@ export default function PerfilAluno() {
const { isDarkMode } = useTheme();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [perfil, setPerfil] = useState<any>(null);
const [estagio, setEstagio] = useState<any>(null);
const [contagemPresencas, setContagemPresencas] = useState(0);
const [contagemFaltas, setContagemFaltas] = useState(0);
// --- FUNÇÕES DE FORMATAÇÃO E CÁLCULO ---
const formatarParaExibir = (data: string) => {
if (!data) return '';
const [ano, mes, dia] = data.split('-');
return `${dia}-${mes}-${ano}`;
};
const formatarParaSalvar = (data: string) => {
if (!data || data.length < 10) return null;
const [dia, mes, ano] = data.split('-');
return `${ano}-${mes}-${dia}`;
};
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)}`;
return formatted;
};
// NOVA FUNÇÃO: Calcula horas totais excluindo fins de semana e feriados
const calcularHorasTotaisUteis = (dataInicio: string, dataFim: string) => {
if (!dataInicio || !dataFim) return 400; // Fallback caso a BD esteja vazia
let inicio = new Date(dataInicio);
let fim = new Date(dataFim);
let diasUteis = 0;
// Lista de feriados 2026 (Exemplo Portugal)
const feriados = [
'2026-01-01', '2026-04-03', '2026-04-05', '2026-04-25',
'2026-05-01', '2026-06-04', '2026-06-10', '2026-08-15',
'2026-10-05', '2026-11-01', '2026-12-01', '2026-12-08', '2026-12-25'
];
let dataAtual = new Date(inicio);
while (dataAtual <= fim) {
const diaSemana = dataAtual.getDay(); // 0: Domingo, 6: Sábado
const dataIso = dataAtual.toISOString().split('T')[0];
if (diaSemana !== 0 && diaSemana !== 6 && !feriados.includes(dataIso)) {
diasUteis++;
}
dataAtual.setDate(dataAtual.getDate() + 1);
}
return diasUteis * 7; // Multiplicado pelas 7h diárias
};
const calcularIdade = (dataExibicao: string) => {
if (!dataExibicao || dataExibicao.length < 10) return '--';
try {
const [dia, mes, ano] = dataExibicao.split('-').map(Number);
const hoje = new Date();
const nascimento = new Date(ano, mes - 1, dia);
let idade = hoje.getFullYear() - nascimento.getFullYear();
const m = hoje.getMonth() - nascimento.getMonth();
if (m < 0 || (m === 0 && hoje.getDate() < nascimento.getDate())) {
idade--;
}
return isNaN(idade) ? '--' : idade;
} catch {
return '--';
}
};
useEffect(() => {
carregarDados();
@@ -20,18 +95,24 @@ export default function PerfilAluno() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
// 1. Dados do Perfil
const { data: prof } = await supabase.from('profiles').select('*').eq('id', user.id).single();
setPerfil(prof);
const [perfilRes, estagioRes, presencasRes] = await Promise.all([
supabase.from('profiles').select('*').eq('id', user.id).single(),
supabase.from('estagios').select('*, empresas(*)').eq('aluno_id', user.id).single(),
supabase.from('presencas').select('estado').eq('aluno_id', user.id)
]);
// 2. Dados do Estágio e Empresa (Relacionados)
const { data: est } = await supabase
.from('estagios')
.select('*, empresas(*)')
.eq('aluno_id', user.id)
.single();
setEstagio(est);
const dadosPerfil = perfilRes.data;
if (dadosPerfil?.data_nascimento) {
dadosPerfil.data_nascimento = formatarParaExibir(dadosPerfil.data_nascimento);
}
setPerfil({ ...dadosPerfil, email: user.email });
setEstagio(estagioRes.data);
if (presencasRes.data) {
setContagemPresencas(presencasRes.data.filter((p: any) => p.estado === 'presente').length);
setContagemFaltas(presencasRes.data.filter((p: any) => p.estado === 'faltou').length);
}
} catch (e) {
console.error(e);
} finally {
@@ -39,63 +120,233 @@ export default function PerfilAluno() {
}
}
const themeStyles = {
fundo: isDarkMode ? '#121212' : '#f1f3f5',
card: isDarkMode ? '#1e1e1e' : '#fff',
texto: isDarkMode ? '#fff' : '#000',
textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d',
const salvarPerfil = async () => {
try {
const dataBD = formatarParaSalvar(perfil.data_nascimento);
const { error } = await supabase.from('profiles').update({
nome: perfil.nome,
telefone: perfil.telefone,
residencia: perfil.residencia,
data_nascimento: dataBD
}).eq('id', perfil.id);
if (error) throw error;
setIsEditing(false);
Alert.alert("Sucesso", "Perfil atualizado!");
} catch (e) {
Alert.alert("Erro", "Verifica se a data está correta (DD-MM-AAAA).");
}
};
if (loading) return <ActivityIndicator style={{flex:1}} color="#0d6efd" />;
// CÁLCULOS DO DASHBOARD
const horasPorDia = 7;
const horasTotais = calcularHorasTotaisUteis(estagio?.data_inicio, estagio?.data_fim);
const horasRealizadas = contagemPresencas * horasPorDia;
const horasRestantes = Math.max(0, horasTotais - horasRealizadas);
const progresso = horasTotais > 0 ? Math.min(1, horasRealizadas / horasTotais) : 0;
const themeStyles = {
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: '#3B82F6',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
vermelho: '#EF4444'
};
if (loading) return <View style={[styles.centered, { backgroundColor: themeStyles.fundo }]}><ActivityIndicator size="large" color={themeStyles.azul} /></View>;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: themeStyles.fundo }]}>
<ScrollView contentContainerStyle={styles.container}>
<Text style={[styles.tituloGeral, { color: themeStyles.texto }]}>O Meu Perfil</Text>
{/* Dados Pessoais vindos da tabela PROFILES */}
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
<Text style={styles.tituloCard}>Dados Pessoais</Text>
<LabelValor label="Nome" valor={perfil?.nome} theme={themeStyles} />
<LabelValor label="Idade" valor={`${perfil?.idade} anos`} theme={themeStyles} />
<LabelValor label="Residência" valor={perfil?.residencia} theme={themeStyles} />
<LabelValor label="Telemóvel" valor={perfil?.telefone} theme={themeStyles} />
</View>
{/* Dados da Empresa vindos da tabela EMPRESAS via Estágio */}
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
<Text style={styles.tituloCard}>Local de Estágio</Text>
<LabelValor label="Empresa" valor={estagio?.empresas?.nome} theme={themeStyles} />
<LabelValor label="Tutor" valor={estagio?.empresas?.tutor_nome} theme={themeStyles} />
<LabelValor label="Contacto Tutor" valor={estagio?.empresas?.tutor_telefone} theme={themeStyles} />
<LabelValor label="Morada" valor={estagio?.empresas?.morada} theme={themeStyles} />
</View>
<TouchableOpacity
style={styles.btnSair}
onPress={async () => { await supabase.auth.signOut(); router.replace('/'); }}
>
<Text style={{color: '#fff', fontWeight: 'bold'}}>Sair da Conta</Text>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.topNav}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={themeStyles.texto} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<View style={[styles.avatarCircle, { backgroundColor: themeStyles.azul }]}>
<Text style={styles.avatarText}>{perfil?.nome?.charAt(0).toUpperCase()}</Text>
</View>
<Text style={[styles.userName, { color: themeStyles.texto }]}>{perfil?.nome}</Text>
<TouchableOpacity
style={[styles.btnEdit, { backgroundColor: isEditing ? '#10B981' : themeStyles.card, borderColor: themeStyles.borda }]}
onPress={isEditing ? salvarPerfil : () => setIsEditing(true)}
>
<Ionicons name={isEditing ? "checkmark-outline" : "create-outline"} size={18} color={isEditing ? "#fff" : themeStyles.azul} />
<Text style={[styles.btnEditText, { color: isEditing ? "#fff" : themeStyles.texto }]}>
{isEditing ? "Guardar" : "Editar Perfil"}
</Text>
</TouchableOpacity>
</View>
{/* PROGRESSO */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Progresso do Estágio</Text>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<View style={styles.statsGrid}>
<StatBox label="Realizadas" valor={`${horasRealizadas}h`} cor={themeStyles.azul} theme={themeStyles} />
<StatBox label="Faltam" valor={`${horasRestantes}h`} cor="#F59E0B" theme={themeStyles} />
<StatBox label="Faltas" valor={contagemFaltas.toString()} cor={themeStyles.vermelho} theme={themeStyles} />
</View>
<View style={styles.progressContainer}>
<View style={[styles.progressBarBase, { backgroundColor: themeStyles.fundo }]}>
<View style={[styles.progressBarFill, { width: `${progresso * 100}%`, backgroundColor: themeStyles.azul }]} />
</View>
<Text style={[styles.progressText, { color: themeStyles.secundario }]}>
{Math.round(progresso * 100)}% das {horasTotais}h concluídas (Dias Úteis)
</Text>
</View>
</View>
</View>
{/* DADOS ACADÉMICOS */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Dados de Aluno</Text>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<InfoRow icon="id-card-outline" label="Nº de Aluno" valor={perfil?.n_escola || '---'} theme={themeStyles} />
<Divider theme={themeStyles} />
<InfoRow icon="mail-outline" label="E-mail Institucional" valor={perfil?.email} theme={themeStyles} />
</View>
</View>
{/* INFORMAÇÃO PESSOAL */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Informação Pessoal</Text>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<EditableRow icon="person-outline" label="Nome Completo" value={perfil?.nome} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, nome: t})} theme={themeStyles} />
<Divider theme={themeStyles} />
<EditableRow
icon="calendar-outline"
label="Data de Nascimento"
value={perfil?.data_nascimento}
isEditing={isEditing}
onChange={(t: string) => setPerfil({...perfil, data_nascimento: aplicarMascaraData(t)})}
theme={themeStyles}
keyboard="numeric"
limit={10}
/>
<Divider theme={themeStyles} />
<InfoRow
icon="hourglass-outline"
label="Idade Atual"
valor={calcularIdade(perfil?.data_nascimento) !== '--' ? `${calcularIdade(perfil?.data_nascimento)} anos` : '---'}
theme={themeStyles}
/>
<Divider theme={themeStyles} />
<EditableRow icon="call-outline" label="Telemóvel" value={perfil?.telefone} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, telefone: t})} theme={themeStyles} keyboard="phone-pad" />
<Divider theme={themeStyles} />
<EditableRow icon="location-outline" label="Residência" value={perfil?.residencia} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, residencia: t})} theme={themeStyles} />
</View>
</View>
{/* EMPRESA E DATAS DO ESTÁGIO */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Horário e Local</Text>
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
<InfoRow icon="calendar-outline" label="Início do Estágio" valor={formatarParaExibir(estagio?.data_inicio)} theme={themeStyles} />
<Divider theme={themeStyles} />
<InfoRow icon="flag-outline" label="Fim do Estágio" valor={formatarParaExibir(estagio?.data_fim)} theme={themeStyles} />
<Divider theme={themeStyles} />
<InfoRow icon="time-outline" label="Horário Diário" valor={estagio?.horario || "09:00 - 17:00"} theme={themeStyles} />
<Divider theme={themeStyles} />
<InfoRow icon="business-outline" label="Empresa" valor={estagio?.empresas?.nome} theme={themeStyles} />
<Divider theme={themeStyles} />
<InfoRow icon="person-outline" label="Tutor" valor={estagio?.empresas?.tutor_nome} theme={themeStyles} />
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
function LabelValor({ label, valor, theme }: any) {
// --- AUXILIARES (IGUAIS) ---
function StatBox({ label, valor, cor, theme }: any) {
return (
<View style={{ marginBottom: 12 }}>
<Text style={{ fontSize: 11, color: theme.textoSecundario, textTransform: 'uppercase' }}>{label}</Text>
<Text style={{ fontSize: 16, color: theme.texto, fontWeight: '600' }}>{valor || '---'}</Text>
<View style={styles.statBox}>
<Text style={[styles.statValor, { color: cor }]}>{valor}</Text>
<Text style={[styles.statLabel, { color: theme.secundario }]}>{label}</Text>
</View>
);
}
function EditableRow({ icon, label, value, isEditing, onChange, theme, keyboard = "default", limit }: any) {
return (
<View style={styles.infoRow}>
<Ionicons name={icon} size={20} color={theme.azul} style={{ width: 30 }} />
<View style={{ flex: 1 }}>
<Text style={[styles.infoLabel, { color: theme.secundario }]}>{label}</Text>
{isEditing ? (
<TextInput
style={[styles.input, { color: theme.texto, borderBottomColor: theme.azul }]}
value={value}
onChangeText={onChange}
keyboardType={keyboard}
maxLength={limit}
placeholder={label.includes("Data") ? "DD-MM-AAAA" : ""}
placeholderTextColor={theme.secundario}
/>
) : (
<Text style={[styles.infoValor, { color: theme.texto }]}>{value || '---'}</Text>
)}
</View>
</View>
);
}
function InfoRow({ icon, label, valor, theme }: any) {
return (
<View style={styles.infoRow}>
<Ionicons name={icon} size={20} color={theme.azul} style={{ width: 30 }} />
<View style={styles.infoContent}>
<Text style={[styles.infoLabel, { color: theme.secundario }]}>{label}</Text>
<Text style={[styles.infoValor, { color: theme.texto }]}>{valor || '---'}</Text>
</View>
</View>
);
}
const Divider = ({ theme }: any) => <View style={[styles.divider, { backgroundColor: theme.borda }]} />;
const styles = StyleSheet.create({
safe: { flex: 1 },
container: { padding: 20, gap: 20 },
tituloGeral: { fontSize: 24, fontWeight: 'bold', marginBottom: 10 },
card: { padding: 20, borderRadius: 16, elevation: 2 },
tituloCard: { fontSize: 14, fontWeight: 'bold', color: '#0d6efd', marginBottom: 15, textTransform: 'uppercase' },
btnSair: { backgroundColor: '#dc3545', padding: 18, borderRadius: 15, alignItems: 'center', marginTop: 10 }
safe: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 },
topNav: { paddingHorizontal: 20, paddingTop: 10, height: 50, justifyContent: 'center' },
backBtn: { width: 40, height: 40, justifyContent: 'center' },
scrollContent: { paddingHorizontal: 20, paddingBottom: 40 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
header: { alignItems: 'center', marginBottom: 30 },
avatarCircle: { width: 80, height: 80, borderRadius: 40, justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
avatarText: { fontSize: 32, fontWeight: 'bold', color: '#fff' },
userName: { fontSize: 20, fontWeight: '800', marginBottom: 15 },
btnEdit: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, borderWidth: 1 },
btnEditText: { marginLeft: 8, fontSize: 13, fontWeight: '600' },
section: { marginBottom: 25 },
sectionTitle: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10, marginLeft: 5 },
card: { borderRadius: 20, padding: 16, borderWidth: 1 },
statsGrid: { flexDirection: 'row', justifyContent: 'space-around', marginBottom: 20 },
statBox: { alignItems: 'center' },
statValor: { fontSize: 18, fontWeight: 'bold' },
statLabel: { fontSize: 11, fontWeight: '600', marginTop: 2 },
progressContainer: { marginTop: 10 },
progressBarBase: { height: 8, borderRadius: 4, overflow: 'hidden' },
progressBarFill: { height: '100%', borderRadius: 4 },
progressText: { fontSize: 11, textAlign: 'center', marginTop: 8, fontWeight: '600' },
infoRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8 },
infoContent: { flex: 1 },
infoLabel: { fontSize: 10, fontWeight: '600', textTransform: 'uppercase' },
infoValor: { fontSize: 15, fontWeight: '600', marginTop: 2 },
input: { fontSize: 15, fontWeight: '600', paddingVertical: 2, borderBottomWidth: 1 },
divider: { height: 1, width: '100%', marginVertical: 8 }
});

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Platform,
SafeAreaView,
ScrollView,
@@ -18,20 +19,26 @@ import { supabase } from '../../lib/supabase';
interface Presenca {
id: string;
aluno_id: string;
data: string;
estado: string;
sumario: string | null;
justificacao_url: string | null;
created_at: string;
lat?: number;
lng?: number;
}
export default function HistoricoPresencas() {
const router = useRouter();
const { alunoId, nome } = useLocalSearchParams();
const params = useLocalSearchParams();
const { isDarkMode } = useTheme();
const [presencas, setPresencas] = useState<Presenca[]>([]);
const [loading, setLoading] = useState(true);
const idStr = Array.isArray(params.alunoId) ? params.alunoId[0] : params.alunoId;
const nomeStr = Array.isArray(params.nome) ? params.nome[0] : params.nome;
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
@@ -45,8 +52,8 @@ export default function HistoricoPresencas() {
}), [isDarkMode]);
useEffect(() => {
if (alunoId) fetchHistorico();
}, [alunoId]);
if (idStr) fetchHistorico();
}, [idStr]);
async function fetchHistorico() {
try {
@@ -54,40 +61,72 @@ export default function HistoricoPresencas() {
const { data, error } = await supabase
.from('presencas')
.select('*')
.eq('aluno_id', alunoId)
.eq('aluno_id', idStr)
.order('data', { ascending: false });
if (error) throw error;
setPresencas(data || []);
} catch (error: any) {
console.error(error.message);
Alert.alert("Erro", "Falha ao carregar o histórico real.");
Alert.alert("Erro", "Não foi possível carregar as presenças.");
} finally {
setLoading(false);
}
}
const handleNavigation = (item: Presenca) => {
if (item.estado === 'faltou') {
router.push({
pathname: '/Professor/Alunos/Faltas',
params: { alunoId: idStr, nome: nomeStr }
});
} else if (item.estado === 'presente') {
router.push({
pathname: '/Professor/Alunos/Sumarios',
params: { alunoId: idStr, nome: nomeStr }
});
}
};
const abrirMapa = (lat: number, lng: number) => {
const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' });
const latLng = `${lat},${lng}`;
const label = `Localização de ${nomeStr}`;
const url = Platform.select({
ios: `${scheme}${label}@${latLng}`,
android: `${scheme}${latLng}(${label})`
});
if (url) {
Linking.openURL(url).catch(() => {
Alert.alert("Erro", "Não foi possível abrir o mapa.");
});
}
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
{/* StatusBar configurada para não sobrepor o conteúdo */}
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
translucent
backgroundColor="transparent"
/>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
{/* Header com design idêntico ao anterior */}
{/* HEADER PREMIUM */}
<View style={styles.headerFixed}>
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={cores.texto} />
<TouchableOpacity
onPress={() => router.back()}
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="chevron-back" size={22} color={cores.texto} />
</TouchableOpacity>
<View style={{ alignItems: 'center' }}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: cores.texto }]}>Histórico</Text>
<Text style={[styles.subtitle, { color: cores.secundario }]}>{nome}</Text>
<Text style={[styles.subtitle, { color: cores.secundario }]} numberOfLines={1}>{nomeStr}</Text>
</View>
<TouchableOpacity onPress={fetchHistorico} style={styles.refreshBtn}>
<Ionicons name="refresh" size={22} color={cores.azul} />
<TouchableOpacity
onPress={fetchHistorico}
style={[styles.refreshBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="refresh" size={20} color={cores.azul} />
</TouchableOpacity>
</View>
</View>
@@ -97,27 +136,30 @@ export default function HistoricoPresencas() {
<ActivityIndicator size="large" color={cores.azul} />
</View>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{presencas.length === 0 ? (
<View style={styles.empty}>
<Ionicons name="calendar-outline" size={48} color={cores.secundario} style={{ opacity: 0.5 }} />
<Text style={[styles.emptyText, { color: cores.secundario }]}>
Sem registos reais para este aluno.
</Text>
<Text style={[styles.emptyText, { color: cores.secundario }]}>Sem registos para este aluno.</Text>
</View>
) : (
presencas.map((item) => {
const isPresente = item.estado === 'presente';
return (
<View key={item.id} style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]}>
<TouchableOpacity
key={item.id}
activeOpacity={0.7}
onPress={() => handleNavigation(item)}
style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<View style={styles.cardHeader}>
{/* Ícone de Calendário no lugar do Avatar para manter o peso visual */}
<View style={[styles.iconBox, { backgroundColor: cores.azulSuave }]}>
<Ionicons name="calendar" size={20} color={cores.azul} />
<View style={[styles.iconBox, { backgroundColor: isPresente ? cores.azulSuave : 'rgba(239, 68, 68, 0.1)' }]}>
<Ionicons
name={isPresente ? "book-outline" : "alert-circle-outline"}
size={20}
color={isPresente ? cores.azul : cores.vermelho}
/>
</View>
<View style={styles.info}>
@@ -127,24 +169,30 @@ export default function HistoricoPresencas() {
<View style={styles.statusBadge}>
<View style={[styles.dot, { backgroundColor: isPresente ? cores.verde : cores.vermelho }]} />
<Text style={[styles.subText, { color: cores.secundario }]}>
{item.estado.toUpperCase()}
{item.estado ? item.estado.toUpperCase() : '---'}
</Text>
</View>
</View>
{item.lat && (
<Ionicons name="location-sharp" size={18} color={cores.azul} />
)}
<View style={styles.actionRow}>
{item.lat && item.lng && (
<TouchableOpacity
onPress={() => abrirMapa(item.lat!, item.lng!)}
style={styles.locationBtn}
>
<Ionicons name="location-sharp" size={20} color={cores.azul} />
</TouchableOpacity>
)}
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
</View>
</View>
{/* Área do Sumário com o estilo de "nota" do ecrã anterior */}
<View style={[styles.sumarioBox, { backgroundColor: isDarkMode ? 'rgba(255,255,255,0.03)' : '#F1F5F9' }]}>
<Text style={[styles.sumarioLabel, { color: cores.secundario }]}>Sumário do Dia</Text>
<Text style={[styles.sumarioText, { color: cores.texto }]}>
{item.sumario || "O aluno ainda não preencheu o sumário deste dia."}
<View style={[styles.footerCard, { borderTopColor: isDarkMode ? '#2D2D2D' : '#F1F5F9' }]}>
<Text style={[styles.tapInfo, { color: isPresente ? cores.azul : cores.vermelho }]}>
Ver {isPresente ? 'sumário detalhado' : 'comprovativo de falta'}
</Text>
</View>
</View>
</TouchableOpacity>
);
})
)}
@@ -155,63 +203,40 @@ export default function HistoricoPresencas() {
}
const styles = StyleSheet.create({
safe: {
flex: 1,
// Garante que o conteúdo não fica debaixo da barra de notificações (Android)
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 },
headerFixed: { paddingHorizontal: 20, paddingBottom: 10 },
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 75 },
backBtnPremium: {
width: 42, height: 42, borderRadius: 14,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1, elevation: 2, shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2
},
headerFixed: {
paddingHorizontal: 20,
paddingBottom: 10
refreshBtnPremium: {
width: 42, height: 42, borderRadius: 14,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1
},
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 70
},
backBtn: { width: 40, height: 40, justifyContent: 'center' },
refreshBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'flex-end' },
title: { fontSize: 22, fontWeight: '800' },
subtitle: { fontSize: 13, marginTop: -2, fontWeight: '600' },
scrollContent: { padding: 20 },
titleContainer: { flex: 1, alignItems: 'center', paddingHorizontal: 10 },
title: { fontSize: 20, fontWeight: '800' },
subtitle: { fontSize: 12, fontWeight: '600', marginTop: -2 },
scrollContent: { paddingHorizontal: 20, paddingBottom: 30 },
card: {
borderRadius: 20,
padding: 16,
marginBottom: 15,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 5
borderRadius: 22, padding: 16, marginBottom: 15,
borderWidth: 1, elevation: 3, shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10
},
cardHeader: { flexDirection: 'row', alignItems: 'center' },
iconBox: {
width: 44,
height: 44,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center'
},
iconBox: { width: 44, height: 44, borderRadius: 15, justifyContent: 'center', alignItems: 'center' },
info: { flex: 1, marginLeft: 15 },
dateText: { fontSize: 16, fontWeight: '700' },
dateText: { fontSize: 15, fontWeight: '700' },
statusBadge: { flexDirection: 'row', alignItems: 'center', marginTop: 4 },
dot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 },
subText: { fontSize: 11, fontWeight: '800', letterSpacing: 0.5 },
sumarioBox: {
marginTop: 15,
padding: 12,
borderRadius: 12,
},
sumarioLabel: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
marginBottom: 5,
letterSpacing: 0.5
},
sumarioText: { fontSize: 13, lineHeight: 18, opacity: 0.9 },
subText: { fontSize: 10, fontWeight: '800', letterSpacing: 0.5 },
actionRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
locationBtn: { width: 34, height: 34, justifyContent: 'center', alignItems: 'center' },
footerCard: { marginTop: 12, paddingTop: 10, borderTopWidth: 1 },
tapInfo: { fontSize: 10, fontWeight: '800', textAlign: 'right', textTransform: 'uppercase', letterSpacing: 0.5 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { alignItems: 'center', marginTop: 60 },
emptyText: { marginTop: 15, fontSize: 14, fontWeight: '600', textAlign: 'center' },

View File

@@ -55,7 +55,6 @@ export default function Estagios() {
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}), [isDarkMode]);
// --- Estados ---
const [estagios, setEstagios] = useState<Estagio[]>([]);
const [alunos, setAlunos] = useState<Aluno[]>([]);
const [empresas, setEmpresas] = useState<Empresa[]>([]);
@@ -187,26 +186,26 @@ export default function Estagios() {
return groups;
}, [alunos, searchAluno]);
// CORREÇÃO DO AGRUPAMENTO DE EMPRESAS PARA EVITAR DUPLICADOS
const empresasAgrupadas = useMemo(() => {
const groups: Record<string, Empresa[]> = {};
empresas.filter(e => e.nome.toLowerCase().includes(searchEmpresa.toLowerCase())).forEach(e => {
const k = e.curso || 'Geral';
if (!groups[k]) groups[k] = [];
groups[k].push(e);
});
empresas
.filter(e => e.nome.toLowerCase().includes(searchEmpresa.toLowerCase()))
.forEach(e => {
const k = e.curso ? e.curso.trim() : 'Geral';
if (!groups[k]) groups[k] = [];
groups[k].push(e);
});
return groups;
}, [empresas, searchEmpresa]);
// --- NOVA LÓGICA DE AGRUPAMENTO (APENAS ISTO FOI ADICIONADO PARA A VIEW) ---
const estagiosAgrupados = useMemo(() => {
const groups: Record<string, Estagio[]> = {};
estagios.filter(e => e.alunos?.nome?.toLowerCase().includes(searchMain.toLowerCase())).forEach(e => {
const chave = e.alunos ? `${e.alunos.ano}º ${e.alunos.turma_curso}` : 'Sem Turma';
if (!groups[chave]) groups[chave] = [];
groups[chave].push(e);
});
return Object.keys(groups).map(titulo => ({
titulo,
dados: groups[titulo]
@@ -221,11 +220,17 @@ export default function Estagios() {
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={[styles.btnCircle, { backgroundColor: cores.card }]} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={22} color={cores.texto}/>
<TouchableOpacity
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={22} color={cores.texto}/>
</TouchableOpacity>
<Text style={[styles.tituloGeral, { color: cores.texto }]}>Estágios</Text>
<TouchableOpacity style={[styles.btnCircle, { backgroundColor: cores.card }]} onPress={fetchDados}>
<TouchableOpacity
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
onPress={fetchDados}
>
<Ionicons name="refresh" size={20} color={cores.azul}/>
</TouchableOpacity>
</View>
@@ -241,7 +246,6 @@ export default function Estagios() {
/>
</View>
{/* RENDERING AGRUPADO AQUI */}
{estagiosAgrupados.map(grupo => (
<View key={grupo.titulo} style={{ marginBottom: 20 }}>
<View style={[styles.turmaSectionHeader, { backgroundColor: cores.azulSuave }]}>
@@ -296,19 +300,22 @@ export default function Estagios() {
</ScrollView>
</SafeAreaView>
<Modal visible={modalVisible} animationType="fade" transparent>
<Modal visible={modalVisible} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: cores.texto }]}>
{passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"}
</Text>
<TouchableOpacity onPress={handleFecharModal}>
<Ionicons name="close-circle" size={28} color={cores.secundario} />
<View>
<Text style={[styles.modalTitle, { color: cores.texto }]}>
{passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"}
</Text>
{passo === 2 && <Text style={[styles.miniLabel, {marginTop: 2}]}>{alunoSelecionado?.nome}</Text>}
</View>
<TouchableOpacity onPress={handleFecharModal} style={[styles.closeBtn, {backgroundColor: cores.fundo}]}>
<Ionicons name="close" size={22} color={cores.texto} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{paddingBottom: 40}}>
{passo === 1 ? (
<View style={{ gap: 15 }}>
<Text style={[styles.sectionLabel, { color: cores.secundario }]}>SELECIONE O ALUNO</Text>
@@ -337,10 +344,10 @@ export default function Estagios() {
<Text style={[styles.sectionLabel, { color: cores.secundario, marginTop: 10 }]}>SELECIONE A EMPRESA</Text>
<View style={[styles.selectorContainer, { borderColor: cores.borda }]}>
<ScrollView nestedScrollEnabled style={{maxHeight: 200}}>
{Object.keys(empresasAgrupadas).map(c => (
<View key={c}>
<Text style={[styles.groupHead, { color: cores.azul, backgroundColor: cores.azulSuave }]}>{c}</Text>
{empresasAgrupadas[c].map(emp => (
{Object.keys(empresasAgrupadas).sort().map(curso => (
<View key={curso}>
<Text style={[styles.groupHead, { color: cores.azul, backgroundColor: cores.azulSuave }]}>{curso.toUpperCase()}</Text>
{empresasAgrupadas[curso].map(emp => (
<TouchableOpacity
key={emp.id}
style={[styles.item, empresaSelecionada?.id === emp.id && { backgroundColor: cores.azulSuave }]}
@@ -364,11 +371,11 @@ export default function Estagios() {
<View style={{flexDirection: 'row', gap: 10, marginTop: 8}}>
<View style={{flex:1}}>
<Text style={styles.miniLabel}>INÍCIO</Text>
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataInicio} onChangeText={setDataInicio} placeholder="2024-09-01"/>
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataInicio} onChangeText={setDataInicio} placeholder="AAAA-MM-DD"/>
</View>
<View style={{flex:1}}>
<Text style={styles.miniLabel}>FIM</Text>
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataFim} onChangeText={setDataFim} placeholder="2025-06-30"/>
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataFim} onChangeText={setDataFim} placeholder="AAAA-MM-DD"/>
</View>
</View>
<View style={[styles.badge, { backgroundColor: cores.azul, marginTop: 12, alignSelf: 'flex-start' }]}>
@@ -436,17 +443,19 @@ export default function Estagios() {
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingVertical: 15 },
btnCircle: { width: 42, height: 42, borderRadius: 12, justifyContent: 'center', alignItems: 'center', elevation: 2 },
tituloGeral: { fontSize: 18, fontWeight: '800' },
backBtnPremium: {
width: 42, height: 42, borderRadius: 14,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1, elevation: 2, shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2
},
tituloGeral: { fontSize: 20, fontWeight: '800' },
scrollContent: { paddingHorizontal: 20, paddingBottom: 40, gap: 15 },
searchContainer: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 15, height: 50, borderRadius: 15, borderWidth: 1, marginBottom: 5 },
searchInput: { flex: 1, marginLeft: 10, fontSize: 14, fontWeight: '500' },
// ADICIONADO APENAS PARA O CABEÇALHO DO AGRUPAMENTO:
turmaSectionHeader: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 },
turmaSectionText: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5 },
card: { padding: 18, borderRadius: 24, flexDirection: 'row', alignItems: 'center', marginBottom: 2, elevation: 3, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 10 },
card: { padding: 18, borderRadius: 24, flexDirection: 'row', alignItems: 'center', marginBottom: 2, elevation: 3, shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 10 },
cardTitle: { fontSize: 15, fontWeight: '700', marginBottom: 4 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
cardSub: { fontSize: 11, fontWeight: '600' },
@@ -457,9 +466,10 @@ const styles = StyleSheet.create({
btnPrincipal: { height: 56, borderRadius: 16, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 10, gap: 10, elevation: 4 },
btnPrincipalText: { color: '#fff', fontSize: 14, fontWeight: '800' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' },
modalContent: { borderTopLeftRadius: 32, borderTopRightRadius: 32, padding: 24, height: '85%' },
modalContent: { borderTopLeftRadius: 32, borderTopRightRadius: 32, padding: 24, height: '88%' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 },
modalTitle: { fontSize: 17, fontWeight: '800' },
modalTitle: { fontSize: 18, fontWeight: '800' },
closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' },
sectionLabel: { fontSize: 10, fontWeight: '900', textTransform: 'uppercase', letterSpacing: 1.2 },
selectorContainer: { borderWidth: 1, borderRadius: 16, overflow: 'hidden', marginTop: 8 },
groupHead: { fontSize: 10, padding: 8, fontWeight: '800', textTransform: 'uppercase' },

View File

@@ -1,7 +1,11 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { memo, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Linking,
Modal,
Platform,
SafeAreaView,
@@ -9,262 +13,307 @@ import {
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
View
} from 'react-native';
import { WebView } from 'react-native-webview';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
export interface Aluno {
id: string;
nome: string;
n_escola: string;
turma: string;
}
interface Falta {
dia: string;
pdfUrl?: string;
id: string;
data: string;
justificacao_url: string | null;
estado: string;
}
interface Aluno {
id: number;
nome: string;
faltas: Falta[];
}
// Dados simulados
const alunosData: Aluno[] = [
{
id: 1,
nome: 'João Silva',
faltas: [
{
dia: '2026-01-20',
pdfUrl: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
},
{ dia: '2026-01-22' },
],
},
{
id: 2,
nome: 'Maria Fernandes',
faltas: [
{ dia: '2026-01-21' },
{
dia: '2026-01-23',
pdfUrl: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
},
],
},
];
export default function FaltasAlunos() {
const router = useRouter();
const FaltasAlunos = memo(() => {
const { isDarkMode } = useTheme();
const router = useRouter();
const params = useLocalSearchParams();
const cores = {
fundo: isDarkMode ? '#121212' : '#f1f3f5',
card: isDarkMode ? '#1e1e1e' : '#fff',
texto: isDarkMode ? '#fff' : '#212529',
textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d',
azul: '#0d6efd',
verde: '#198754',
vermelho: '#dc3545',
const [search, setSearch] = useState('');
const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]);
const [loading, setLoading] = useState(true);
const [modalFaltasVisible, setModalFaltasVisible] = useState(false);
const [alunoSelecionado, setAlunoSelecionado] = useState<Aluno | null>(null);
const [faltas, setFaltas] = useState<Falta[]>([]);
const [loadingFaltas, setLoadingFaltas] = useState(false);
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: '#3B82F6',
vermelho: '#EF4444',
verde: '#10B981',
azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}), [isDarkMode]);
useEffect(() => { fetchAlunos(); }, []);
useEffect(() => {
if (params.alunoId && typeof params.alunoId === 'string') {
const alunoAuto = {
id: params.alunoId,
nome: (params.nome as string) || 'Aluno',
n_escola: '',
turma: '',
};
abrirFaltas(alunoAuto);
}
}, [params.alunoId]);
const fetchAlunos = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('alunos')
.select('id, nome, n_escola, ano, turma_curso')
.order('ano', { ascending: false })
.order('nome', { ascending: true });
if (error) throw error;
const agrupadas: Record<string, Aluno[]> = {};
data?.forEach(item => {
const nomeTurma = `${item.ano}º ${item.turma_curso}`;
if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = [];
agrupadas[nomeTurma].push({ id: item.id, nome: item.nome, n_escola: item.n_escola, turma: nomeTurma });
});
setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] })));
} catch (err) { console.error(err); } finally { setLoading(false); }
};
const [alunoSelecionado, setAlunoSelecionado] = useState<Aluno | null>(null);
const [pdfModalVisible, setPdfModalVisible] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const abrirFaltas = async (aluno: Aluno) => {
setAlunoSelecionado(aluno);
setModalFaltasVisible(true);
setLoadingFaltas(true);
try {
const { data, error } = await supabase
.from('presencas')
.select('id, data, justificacao_url, estado')
.eq('aluno_id', aluno.id)
.eq('estado', 'faltou')
.order('data', { ascending: false });
const verPdf = (falta: Falta) => {
if (falta.pdfUrl) {
setPdfUrl(falta.pdfUrl);
setPdfModalVisible(true);
if (error) throw error;
setFaltas(data || []);
} catch (err) { console.error(err); } finally { setLoadingFaltas(false); }
};
const verDocumento = async (url: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Erro", "Não foi possível abrir o link do documento.");
}
} catch (error) {
Alert.alert("Erro", "URL inválida ou documento inacessível.");
}
};
const filteredTurmas = turmas
.map(turma => ({
...turma,
alunos: turma.alunos.filter(a => a.nome.toLowerCase().includes(search.toLowerCase()) || a.n_escola.includes(search)),
}))
.filter(t => t.alunos.length > 0);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={cores.fundo}
/>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
<ScrollView contentContainerStyle={styles.container}>
{/* Top Bar */}
<View style={styles.headerFixed}>
<View style={styles.topBar}>
<TouchableOpacity
onPress={() => {
if (alunoSelecionado) {
setAlunoSelecionado(null);
} else {
router.back();
}
}}
<TouchableOpacity
onPress={() => router.back()}
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="arrow-back-outline" size={26} color={cores.azul} />
<Ionicons name="chevron-back" size={22} color={cores.texto} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: cores.texto }]}>
{!alunoSelecionado ? 'Faltas dos Alunos' : alunoSelecionado.nome}
</Text>
<View style={{ width: 26 }} />
<Text style={[styles.title, { color: cores.texto }]}>Gestão de Faltas</Text>
<View style={{ width: 42 }} />
</View>
{/* Lista de alunos */}
{!alunoSelecionado && (
<View style={{ gap: 12 }}>
{alunosData.map(aluno => (
<TouchableOpacity
key={aluno.id}
style={[styles.alunoCard, { backgroundColor: cores.card }]}
onPress={() => setAlunoSelecionado(aluno)}
>
<Ionicons name="person-outline" size={28} color={cores.azul} />
<Text style={[styles.alunoName, { color: cores.texto }]}>
{aluno.nome}
</Text>
<Ionicons
name="chevron-forward-outline"
size={20}
color={cores.textoSecundario}
/>
</TouchableOpacity>
))}
</View>
)}
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
<Ionicons name="search" size={20} color={cores.secundario} />
<TextInput
placeholder="Procurar aluno..."
placeholderTextColor={cores.secundario}
value={search}
onChangeText={setSearch}
style={[styles.searchInput, { color: cores.texto }]}
/>
</View>
</View>
{/* Faltas do aluno */}
{alunoSelecionado && (
<View style={{ marginTop: 10 }}>
{alunoSelecionado.faltas.map((falta, idx) => (
<View
key={idx}
style={[styles.faltaCard, { backgroundColor: cores.card }]}
>
<View style={{ flex: 1 }}>
<Text style={[styles.dia, { color: cores.azul }]}>
{falta.dia}
</Text>
<Text
style={[
styles.status,
{
color: falta.pdfUrl
? cores.verde
: cores.vermelho,
},
]}
>
{falta.pdfUrl
? 'Falta justificada'
: 'Falta não justificada'}
</Text>
</View>
{falta.pdfUrl && (
<TouchableOpacity onPress={() => verPdf(falta)}>
<Ionicons
name="document-text-outline"
size={24}
color={cores.azul}
/>
</TouchableOpacity>
)}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color={cores.azul} />
</View>
) : (
<FlatList
data={filteredTurmas}
keyExtractor={item => item.nome}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.section}>
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.turmaLabel, { color: cores.azul }]}>{item.nome}</Text>
</View>
))}
</View>
)}
</ScrollView>
{/* Modal PDF */}
<Modal visible={pdfModalVisible} animationType="slide">
<SafeAreaView style={{ flex: 1, backgroundColor: cores.fundo }}>
<View
style={[
styles.pdfHeader,
{ borderBottomColor: cores.textoSecundario },
]}
>
<TouchableOpacity onPress={() => setPdfModalVisible(false)}>
<Ionicons name="close-outline" size={28} color={cores.azul} />
</TouchableOpacity>
<Text style={[styles.pdfTitle, { color: cores.texto }]}>
Visualizador de PDF
</Text>
<View style={{ width: 28 }} />
</View>
{item.alunos.map(aluno => (
<TouchableOpacity
key={aluno.id}
activeOpacity={0.7}
style={[styles.card, { backgroundColor: cores.card }]}
onPress={() => abrirFaltas(aluno)}
>
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.avatarText, { color: cores.azul }]}>{aluno.nome.charAt(0)}</Text>
</View>
<View style={styles.info}>
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
<Text style={[styles.subText, { color: cores.secundario }]}> {aluno.n_escola}</Text>
</View>
<Ionicons name="alert-circle-outline" size={20} color={cores.vermelho} />
</TouchableOpacity>
))}
</View>
)}
/>
)}
{pdfUrl && <WebView source={{ uri: pdfUrl }} style={{ flex: 1 }} />}
</SafeAreaView>
<Modal
visible={modalFaltasVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setModalFaltasVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
<View style={[styles.modalHeader, { borderBottomColor: cores.borda }]}>
<View>
<Text style={[styles.modalTitle, { color: cores.texto }]}>Histórico de Faltas</Text>
<Text style={[styles.modalSubtitle, { color: cores.secundario }]}>{alunoSelecionado?.nome}</Text>
</View>
<TouchableOpacity
onPress={() => setModalFaltasVisible(false)}
style={[styles.closeBtn, { backgroundColor: cores.fundo }]}
>
<Ionicons name="close" size={22} color={cores.texto} />
</TouchableOpacity>
</View>
{loadingFaltas ? (
<ActivityIndicator style={{ marginTop: 50 }} color={cores.azul} />
) : (
<ScrollView contentContainerStyle={{ padding: 20 }} showsVerticalScrollIndicator={false}>
{faltas.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="checkmark-circle-outline" size={48} color={cores.verde} style={{ opacity: 0.3 }} />
<Text style={[styles.emptyText, { color: cores.secundario }]}>Nenhuma falta registada.</Text>
</View>
) : (
faltas.map(f => (
<View key={f.id} style={[styles.faltaCard, { backgroundColor: cores.fundo, borderColor: cores.borda }]}>
<View style={styles.faltaHeader}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="calendar-outline" size={16} color={cores.azul} style={{ marginRight: 8 }} />
<Text style={[styles.faltaData, { color: cores.texto }]}>
{new Date(f.data).toLocaleDateString('pt-PT', { day: '2-digit', month: 'long', year: 'numeric' })}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: f.justificacao_url ? cores.verde + '20' : cores.vermelho + '20' }]}>
<Text style={[styles.statusText, { color: f.justificacao_url ? cores.verde : cores.vermelho }]}>
{f.justificacao_url ? 'Justificada' : 'Injustificada'}
</Text>
</View>
</View>
{f.justificacao_url ? (
<TouchableOpacity
activeOpacity={0.8}
style={[styles.pdfBtn, { backgroundColor: cores.azulSuave }]}
onPress={() => verDocumento(f.justificacao_url!)}
>
<Ionicons name="document-attach" size={18} color={cores.azul} />
<Text style={[styles.pdfBtnText, { color: cores.azul }]}>Ver Comprovativo</Text>
</TouchableOpacity>
) : (
<View style={styles.noJustifContainer}>
<Ionicons name="warning-outline" size={14} color={cores.vermelho} />
<Text style={[styles.noJustifText, { color: cores.vermelho }]}>Aguardando justificação</Text>
</View>
)}
</View>
))
)}
</ScrollView>
)}
</View>
</View>
</Modal>
</SafeAreaView>
);
}
});
export default FaltasAlunos;
const styles = StyleSheet.create({
safe: {
flex: 1,
paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0,
safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 },
headerFixed: { paddingHorizontal: 20, paddingBottom: 15 },
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 70 },
backBtnPremium: {
width: 42, height: 42, borderRadius: 14,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1, elevation: 2, shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2
},
container: { padding: 20, paddingBottom: 40 },
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
topTitle: {
fontSize: 20,
fontWeight: '700',
textAlign: 'center',
flex: 1,
},
alunoCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
alunoName: {
fontSize: 16,
fontWeight: '600',
marginLeft: 12,
flex: 1,
},
faltaCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 14,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
dia: {
fontSize: 14,
fontWeight: '700',
marginBottom: 6,
},
status: {
fontSize: 13,
fontWeight: '600',
},
pdfHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
},
pdfTitle: {
fontSize: 18,
fontWeight: '700',
},
});
title: { fontSize: 22, fontWeight: '800' },
searchBox: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 15, paddingHorizontal: 15, height: 50, marginTop: 10 },
searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' },
scrollContent: { paddingHorizontal: 20, paddingBottom: 30 },
section: { marginBottom: 25 },
turmaBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 },
turmaLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.8 },
card: { flexDirection: 'row', alignItems: 'center', padding: 14, borderRadius: 22, marginBottom: 10, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10 },
avatar: { width: 46, height: 46, borderRadius: 15, justifyContent: 'center', alignItems: 'center' },
avatarText: { fontSize: 18, fontWeight: '800' },
info: { flex: 1, marginLeft: 15 },
nome: { fontSize: 16, fontWeight: '700' },
subText: { fontSize: 12, marginTop: 2, fontWeight: '500' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
modalContent: { height: '85%', borderTopLeftRadius: 30, borderTopRightRadius: 30, overflow: 'hidden' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 25, borderBottomWidth: 1 },
modalTitle: { fontSize: 20, fontWeight: '800' },
modalSubtitle: { fontSize: 14, fontWeight: '600', marginTop: 2 },
closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' },
faltaCard: { padding: 16, borderRadius: 20, marginBottom: 15, borderWidth: 1 },
faltaHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
faltaData: { fontSize: 15, fontWeight: '700' },
statusBadge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
statusText: { fontSize: 10, fontWeight: '900', textTransform: 'uppercase' },
pdfBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderRadius: 12, padding: 12 },
pdfBtnText: { marginLeft: 10, fontSize: 14, fontWeight: '700' },
noJustifContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 5 },
noJustifText: { marginLeft: 6, fontSize: 12, fontWeight: '600', fontStyle: 'italic' },
emptyState: { alignItems: 'center', marginTop: 50 },
emptyText: { textAlign: 'center', marginTop: 15, fontSize: 14, fontWeight: '600' }
});

View File

@@ -16,6 +16,7 @@ import {
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
// --- INTERFACES ---
export interface Aluno {
id: string;
nome: string;
@@ -27,10 +28,12 @@ const ListaAlunosProfessor = memo(() => {
const { isDarkMode } = useTheme();
const router = useRouter();
// --- ESTADOS ---
const [search, setSearch] = useState('');
const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]);
const [loading, setLoading] = useState(true);
// --- CORES DINÂMICAS ---
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
@@ -41,10 +44,7 @@ const ListaAlunosProfessor = memo(() => {
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}), [isDarkMode]);
useEffect(() => {
fetchAlunos();
}, []);
// --- FUNÇÕES ---
const fetchAlunos = async () => {
try {
setLoading(true);
@@ -55,7 +55,6 @@ const ListaAlunosProfessor = memo(() => {
.order('nome', { ascending: true });
if (error) throw error;
if (!data) {
setTurmas([]);
return;
@@ -73,12 +72,7 @@ const ListaAlunosProfessor = memo(() => {
});
});
setTurmas(
Object.keys(agrupadas).map(nome => ({
nome,
alunos: agrupadas[nome],
}))
);
setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] })));
} catch (err) {
console.error('Erro ao carregar alunos:', err);
} finally {
@@ -86,6 +80,10 @@ const ListaAlunosProfessor = memo(() => {
}
};
useEffect(() => {
fetchAlunos();
}, []);
const filteredTurmas = turmas
.map(turma => ({
...turma,
@@ -100,14 +98,17 @@ const ListaAlunosProfessor = memo(() => {
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
{/* HEADER FIXO ESTILO PREMIUM */}
{/* HEADER FIXO */}
<View style={styles.headerFixed}>
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={cores.texto} />
<TouchableOpacity
onPress={() => router.back()}
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="chevron-back" size={22} color={cores.texto} />
</TouchableOpacity>
<Text style={[styles.title, { color: cores.texto }]}>Alunos</Text>
<View style={{ width: 40 }} />
<View style={{ width: 42 }} />
</View>
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
@@ -134,7 +135,6 @@ const ListaAlunosProfessor = memo(() => {
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.section}>
{/* BADGE DA TURMA */}
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.turmaLabel, { color: cores.azul }]}>
{item.nome} {item.alunos.length} Alunos
@@ -157,12 +157,10 @@ const ListaAlunosProfessor = memo(() => {
{aluno.nome.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.info}>
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
<Text style={[styles.subText, { color: cores.secundario }]}> Escola: {aluno.n_escola}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
</TouchableOpacity>
))}
@@ -174,72 +172,115 @@ const ListaAlunosProfessor = memo(() => {
);
});
export default ListaAlunosProfessor;
// --- ESTILOS ---
const styles = StyleSheet.create({
safe: {
flex: 1,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0,
safe: {
flex: 1,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
},
headerFixed: {
paddingHorizontal: 20,
paddingBottom: 15,
headerFixed: {
paddingHorizontal: 20,
paddingBottom: 15
},
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 60,
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 70
},
backBtn: { width: 40, height: 40, justifyContent: 'center' },
title: { fontSize: 24, fontWeight: '800' },
searchBox: {
flexDirection: 'row',
backBtnPremium: {
width: 42,
height: 42,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderRadius: 15,
paddingHorizontal: 15,
height: 48,
marginTop: 5,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
searchInput: { flex: 1, marginLeft: 10, fontSize: 15 },
scrollContent: { paddingHorizontal: 20, paddingBottom: 30 },
section: { marginBottom: 25 },
turmaBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
alignSelf: 'flex-start',
marginBottom: 12,
title: {
fontSize: 22,
fontWeight: '800'
},
searchBox: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 15,
paddingHorizontal: 15,
height: 50,
marginTop: 10
},
searchInput: {
flex: 1,
marginLeft: 10,
fontSize: 15,
fontWeight: '500'
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 30
},
section: {
marginBottom: 25
},
turmaBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
alignSelf: 'flex-start',
marginBottom: 12
},
turmaLabel: {
fontSize: 13,
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5
textTransform: 'uppercase',
letterSpacing: 0.8
},
card: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 20,
marginBottom: 10,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
card: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 22,
marginBottom: 10,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 10
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
avatar: {
width: 46,
height: 46,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center'
},
avatarText: { fontSize: 17, fontWeight: '700' },
info: { flex: 1, marginLeft: 15 },
nome: { fontSize: 16, fontWeight: '700' },
subText: { fontSize: 12, marginTop: 2, fontWeight: '500' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
});
avatarText: {
fontSize: 18,
fontWeight: '800'
},
info: {
flex: 1,
marginLeft: 15
},
nome: {
fontSize: 16,
fontWeight: '700'
},
subText: {
fontSize: 12,
marginTop: 2,
fontWeight: '500'
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
});
export default ListaAlunosProfessor;

View File

@@ -1,11 +1,11 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
@@ -16,82 +16,111 @@ import {
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
interface Aluno {
export interface Aluno {
id: string;
nome: string;
n_escola: string;
turma: string;
}
export default function Presencas() {
const router = useRouter();
const Presencas = memo(() => {
const { isDarkMode } = useTheme();
const [pesquisa, setPesquisa] = useState('');
const [alunos, setAlunos] = useState<Aluno[]>([]);
const router = useRouter();
const [search, setSearch] = useState('');
const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]);
const [loading, setLoading] = useState(true);
const cores = useMemo(
() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: '#3B82F6',
azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}),
[isDarkMode]
);
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: '#3B82F6',
azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}), [isDarkMode]);
useEffect(() => {
fetchAlunos();
}, []);
async function fetchAlunos() {
const fetchAlunos = async () => {
try {
setLoading(true);
// Busca apenas id e nome, filtrando por tipo aluno
const { data, error } = await supabase
.from('profiles')
.select('id, nome')
.eq('tipo', 'aluno')
.from('alunos')
.select('id, nome, n_escola, ano, turma_curso')
.order('ano', { ascending: false })
.order('nome', { ascending: true });
if (error) throw error;
if (data) {
setAlunos(data as Aluno[]);
if (!data) {
setTurmas([]);
return;
}
} catch (error: any) {
console.error("Erro ao carregar alunos:", error.message);
const agrupadas: Record<string, Aluno[]> = {};
data.forEach(item => {
const nomeTurma = `${item.ano}º ${item.turma_curso}`;
if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = [];
agrupadas[nomeTurma].push({
id: item.id,
nome: item.nome,
n_escola: item.n_escola,
turma: nomeTurma,
});
});
setTurmas(
Object.keys(agrupadas).map(nome => ({
nome,
alunos: agrupadas[nome],
}))
);
} catch (err) {
console.error('Erro ao carregar alunos:', err);
} finally {
setLoading(false);
}
}
};
const alunosFiltrados = alunos.filter(a =>
a.nome.toLowerCase().includes(pesquisa.toLowerCase())
);
const filteredTurmas = turmas
.map(turma => ({
...turma,
alunos: turma.alunos.filter(a =>
a.nome.toLowerCase().includes(search.toLowerCase()) ||
a.n_escola.includes(search)
),
}))
.filter(t => t.alunos.length > 0);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
{/* HEADER PREMIUM */}
<View style={styles.headerFixed}>
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={cores.texto} />
<TouchableOpacity
onPress={() => router.back()}
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="chevron-back" size={22} color={cores.texto} />
</TouchableOpacity>
<Text style={[styles.title, { color: cores.texto }]}>Presenças</Text>
<View style={{ width: 40 }} />
<View style={{ width: 42 }} />
</View>
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
<Ionicons name="search" size={20} color={cores.secundario} />
<TextInput
placeholder="Pesquisar aluno..."
placeholder="Procurar aluno ou Nº..."
placeholderTextColor={cores.secundario}
value={pesquisa}
onChangeText={setPesquisa}
value={search}
onChangeText={setSearch}
style={[styles.searchInput, { color: cores.texto }]}
/>
</View>
@@ -102,43 +131,53 @@ export default function Presencas() {
<ActivityIndicator size="large" color={cores.azul} />
</View>
) : (
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{alunosFiltrados.length === 0 ? (
<View style={styles.empty}>
<Text style={{ color: cores.secundario }}>Nenhum aluno encontrado.</Text>
<FlatList
data={filteredTurmas}
keyExtractor={item => item.nome}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.section}>
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.turmaLabel, { color: cores.azul }]}>
{item.nome} {item.alunos.length} Alunos
</Text>
</View>
{item.alunos.map(aluno => (
<TouchableOpacity
key={aluno.id}
style={[styles.card, { backgroundColor: cores.card }]}
onPress={() =>
router.push({
pathname: '/Professor/Alunos/CalendarioPresencas',
params: { alunoId: aluno.id, nome: aluno.nome },
})
}
>
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.avatarText, { color: cores.azul }]}>
{aluno.nome.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.info}>
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
<Text style={[styles.subText, { color: cores.secundario }]}>Ver registo de presenças</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
</TouchableOpacity>
))}
</View>
) : (
alunosFiltrados.map(aluno => (
<TouchableOpacity
key={aluno.id}
style={[styles.card, { backgroundColor: cores.card }]}
onPress={() =>
router.push({
pathname: '/Professor/Alunos/CalendarioPresencas',
params: { alunoId: aluno.id, nome: aluno.nome },
})
}
>
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.avatarText, { color: cores.azul }]}>
{aluno.nome.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.info}>
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
<Text style={[styles.subText, { color: cores.secundario }]}>Ver registo de presenças</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
</TouchableOpacity>
))
)}
</ScrollView>
/>
)}
</SafeAreaView>
);
}
});
export default Presencas;
const styles = StyleSheet.create({
safe: {
@@ -153,9 +192,21 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 60,
height: 70,
},
backBtnPremium: {
width: 42,
height: 42,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
backBtn: { width: 40, height: 40, justifyContent: 'center' },
title: { fontSize: 22, fontWeight: '800' },
searchBox: {
flexDirection: 'row',
@@ -163,33 +214,47 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: 15,
paddingHorizontal: 15,
height: 48,
height: 50,
marginTop: 10,
},
searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' },
scrollContent: { paddingHorizontal: 20, paddingBottom: 30 },
section: { marginBottom: 25 },
turmaBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
alignSelf: 'flex-start',
marginBottom: 12,
},
turmaLabel: {
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.8
},
searchInput: { flex: 1, marginLeft: 10, fontSize: 15 },
scrollContent: { padding: 20 },
card: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderRadius: 18,
borderRadius: 22,
marginBottom: 10,
elevation: 2,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 5,
shadowOpacity: 0.04,
shadowRadius: 10,
},
avatar: {
width: 42,
height: 42,
borderRadius: 21,
width: 46,
height: 46,
borderRadius: 15,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: { fontSize: 16, fontWeight: '700' },
avatarText: { fontSize: 18, fontWeight: '800' },
info: { flex: 1, marginLeft: 15 },
nome: { fontSize: 16, fontWeight: '700' },
subText: { fontSize: 12, marginTop: 2 },
subText: { fontSize: 12, marginTop: 2, fontWeight: '500' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { alignItems: 'center', marginTop: 40 },
});

View File

@@ -1,156 +1,291 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { memo, useMemo, useState } from 'react';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { memo, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Modal,
Platform,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useTheme } from '../../../themecontext'; // Contexto global do tema
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
export interface Aluno {
id: string;
nome: string;
n_escola: string;
turma: string;
}
interface Sumario {
dia: string;
conteudo: string;
id: string;
data: string;
sumario: string;
}
interface Aluno {
id: number;
nome: string;
sumarios: Sumario[];
}
// Dados simulados
const alunosData: Aluno[] = [
{
id: 1,
nome: 'João Silva',
sumarios: [
{ dia: '2026-01-20', conteudo: 'Aprendeu sobre React Native e navegação.' },
{ dia: '2026-01-21', conteudo: 'Configurou Supabase e salvou dados.' },
],
},
{
id: 2,
nome: 'Maria Fernandes',
sumarios: [
{ dia: '2026-01-20', conteudo: 'Estudou TypeScript e componentes.' },
{ dia: '2026-01-21', conteudo: 'Criou layout de página de empresas.' },
],
},
];
export default memo(function SumariosAlunos() {
const SumariosAlunos = memo(() => {
const { isDarkMode } = useTheme();
const router = useRouter();
const { isDarkMode } = useTheme(); // pega tema global
const [alunoSelecionado, setAlunoSelecionado] = useState<Aluno | null>(null);
const params = useLocalSearchParams();
// Cores dinamicas
const cores = useMemo(
() => ({
fundo: isDarkMode ? '#121212' : '#f1f3f5',
card: isDarkMode ? '#1e1e1e' : '#fff',
texto: isDarkMode ? '#fff' : '#212529',
textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d',
azul: '#0d6efd',
}),
[isDarkMode]
);
const [search, setSearch] = useState('');
const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [alunoSelecionado, setAlunoSelecionado] = useState<Aluno | null>(null);
const [sumarios, setSumarios] = useState<Sumario[]>([]);
const [loadingSumarios, setLoadingSumarios] = useState(false);
const cores = useMemo(() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
azul: '#3B82F6',
azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)',
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
}), [isDarkMode]);
useEffect(() => {
fetchAlunos();
}, []);
useEffect(() => {
if (params.alunoId && typeof params.alunoId === 'string') {
const alunoAuto = {
id: params.alunoId,
nome: (params.nome as string) || 'Aluno',
n_escola: '',
turma: ''
};
abrirSumarios(alunoAuto);
}
}, [params.alunoId]);
const fetchAlunos = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('alunos')
.select('id, nome, n_escola, ano, turma_curso')
.order('ano', { ascending: false })
.order('nome', { ascending: true });
if (error) throw error;
const agrupadas: Record<string, Aluno[]> = {};
data?.forEach(item => {
const nomeTurma = `${item.ano}º ${item.turma_curso}`;
if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = [];
agrupadas[nomeTurma].push({
id: item.id,
nome: item.nome,
n_escola: item.n_escola,
turma: nomeTurma,
});
});
setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] })));
} catch (err) {
console.error('Erro ao carregar alunos:', err);
} finally {
setLoading(false);
}
};
const abrirSumarios = async (aluno: Aluno) => {
setAlunoSelecionado(aluno);
setModalVisible(true);
setLoadingSumarios(true);
try {
const { data, error } = await supabase
.from('presencas')
.select('id, data, sumario')
.eq('aluno_id', aluno.id)
.not('sumario', 'is', null)
.order('data', { ascending: false });
if (error) throw error;
setSumarios(data || []);
} catch (err) {
console.error('Erro sumários:', err);
} finally {
setLoadingSumarios(false);
}
};
const filteredTurmas = turmas
.map(turma => ({
...turma,
alunos: turma.alunos.filter(a =>
a.nome.toLowerCase().includes(search.toLowerCase()) || a.n_escola.includes(search)
),
}))
.filter(t => t.alunos.length > 0);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={cores.fundo}
/>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
<ScrollView contentContainerStyle={styles.container}>
{/* Cabeçalho */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => (alunoSelecionado ? setAlunoSelecionado(null) : router.back())}
style={styles.backButtonHeader}
<View style={styles.headerFixed}>
<View style={styles.topBar}>
<TouchableOpacity
onPress={() => router.back()}
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
>
<Ionicons name="arrow-back" size={24} color={cores.azul} />
<Ionicons name="chevron-back" size={22} color={cores.texto} />
</TouchableOpacity>
<Text style={[styles.title, { color: cores.texto }]}>Sumários de Estágio</Text>
<Text style={[styles.title, { color: cores.texto }]}>Sumários</Text>
<View style={{ width: 42 }} />
</View>
{/* Lista de alunos */}
{!alunoSelecionado && (
<View style={styles.alunosList}>
{alunosData.map(aluno => (
<TouchableOpacity
key={aluno.id}
style={[styles.alunoCard, { backgroundColor: cores.card }]}
onPress={() => setAlunoSelecionado(aluno)}
>
<Ionicons name="person-outline" size={28} color={cores.azul} />
<Text style={[styles.alunoName, { color: cores.texto }]}>{aluno.nome}</Text>
<Ionicons name="chevron-forward-outline" size={20} color={cores.textoSecundario} />
</TouchableOpacity>
))}
</View>
)}
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
<Ionicons name="search" size={20} color={cores.secundario} />
<TextInput
placeholder="Procurar aluno..."
placeholderTextColor={cores.secundario}
value={search}
onChangeText={setSearch}
style={[styles.searchInput, { color: cores.texto }]}
/>
</View>
</View>
{/* Sumários do aluno */}
{alunoSelecionado && (
<View style={styles.sumariosContainer}>
<Text style={[styles.alunoTitle, { color: cores.texto }]}>{alunoSelecionado.nome}</Text>
{alunoSelecionado.sumarios.map((s, idx) => (
<View
key={idx}
style={[styles.sumarioCard, { backgroundColor: cores.card }]}
>
<Text style={[styles.dia, { color: cores.azul }]}>{s.dia}</Text>
<Text style={[styles.conteudo, { color: cores.texto }]}>{s.conteudo}</Text>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color={cores.azul} />
</View>
) : (
<FlatList
data={filteredTurmas}
keyExtractor={item => item.nome}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.section}>
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.turmaLabel, { color: cores.azul }]}>{item.nome}</Text>
</View>
))}
{item.alunos.map(aluno => (
<TouchableOpacity
key={aluno.id}
activeOpacity={0.7}
style={[styles.card, { backgroundColor: cores.card }]}
onPress={() => abrirSumarios(aluno)}
>
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
<Text style={[styles.avatarText, { color: cores.azul }]}>{aluno.nome.charAt(0)}</Text>
</View>
<View style={styles.info}>
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
<Text style={[styles.subText, { color: cores.secundario }]}> {aluno.n_escola}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
</TouchableOpacity>
))}
</View>
)}
/>
)}
<Modal
visible={modalVisible}
animationType="slide"
transparent={true}
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
<View style={[styles.modalHeader, { borderBottomColor: cores.borda }]}>
<View>
<Text style={[styles.modalTitle, { color: cores.texto }]}>Sumários</Text>
<Text style={[styles.modalSubtitle, { color: cores.secundario }]}>{alunoSelecionado?.nome}</Text>
</View>
<TouchableOpacity
onPress={() => setModalVisible(false)}
style={[styles.closeBtn, { backgroundColor: cores.fundo }]}
>
<Ionicons name="close" size={22} color={cores.texto} />
</TouchableOpacity>
</View>
{loadingSumarios ? (
<ActivityIndicator style={{ marginTop: 50 }} color={cores.azul} />
) : (
<ScrollView contentContainerStyle={{ padding: 20 }} showsVerticalScrollIndicator={false}>
{sumarios.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="document-text-outline" size={48} color={cores.secundario} style={{ opacity: 0.3 }} />
<Text style={[styles.emptyText, { color: cores.secundario }]}>Sem registos de sumários.</Text>
</View>
) : (
sumarios.map(s => (
<View key={s.id} style={[styles.sumarioCard, { backgroundColor: cores.fundo, borderColor: cores.borda }]}>
<View style={styles.sumarioHeader}>
<Ionicons name="calendar-outline" size={14} color={cores.azul} />
<Text style={[styles.sumarioData, { color: cores.azul }]}>
{new Date(s.data).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</Text>
</View>
<Text style={[styles.sumarioTexto, { color: cores.texto }]}>{s.sumario}</Text>
</View>
))
)}
</ScrollView>
)}
</View>
)}
</ScrollView>
</View>
</Modal>
</SafeAreaView>
);
});
export default SumariosAlunos;
const styles = StyleSheet.create({
safe: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 },
container: { padding: 20, paddingBottom: 40 },
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
justifyContent: 'center',
position: 'relative',
safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 },
headerFixed: { paddingHorizontal: 20, paddingBottom: 15 },
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 70 },
backBtnPremium: {
width: 42, height: 42, borderRadius: 14,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1, elevation: 2, shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2
},
backButtonHeader: { position: 'absolute', left: 0, padding: 4 },
title: { fontSize: 24, fontWeight: '700', textAlign: 'center' },
alunosList: { gap: 12 },
alunoCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
alunoName: { fontSize: 16, fontWeight: '600', marginLeft: 12, flex: 1 },
sumariosContainer: { marginTop: 10 },
alunoTitle: { fontSize: 20, fontWeight: '700', marginBottom: 12 },
sumarioCard: { padding: 16, borderRadius: 14, marginBottom: 12 },
dia: { fontSize: 14, fontWeight: '700', marginBottom: 6 },
conteudo: { fontSize: 14, lineHeight: 20 },
});
title: { fontSize: 22, fontWeight: '800' },
searchBox: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 15, paddingHorizontal: 15, height: 50, marginTop: 10 },
searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' },
scrollContent: { paddingHorizontal: 20, paddingBottom: 30 },
section: { marginBottom: 25 },
turmaBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 },
turmaLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.8 },
card: { flexDirection: 'row', alignItems: 'center', padding: 14, borderRadius: 22, marginBottom: 10, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10 },
avatar: { width: 46, height: 46, borderRadius: 15, justifyContent: 'center', alignItems: 'center' },
avatarText: { fontSize: 18, fontWeight: '800' },
info: { flex: 1, marginLeft: 15 },
nome: { fontSize: 16, fontWeight: '700' },
subText: { fontSize: 12, marginTop: 2, fontWeight: '500' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
modalContent: { height: '85%', borderTopLeftRadius: 30, borderTopRightRadius: 30, overflow: 'hidden' },
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 25, borderBottomWidth: 1 },
modalTitle: { fontSize: 20, fontWeight: '800' },
modalSubtitle: { fontSize: 14, fontWeight: '600', marginTop: 2 },
closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' },
sumarioCard: { padding: 16, borderRadius: 18, marginBottom: 15, borderWidth: 1 },
sumarioHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
sumarioData: { fontSize: 13, fontWeight: '800', marginLeft: 6 },
sumarioTexto: { fontSize: 15, lineHeight: 22, fontWeight: '500' },
emptyState: { alignItems: 'center', marginTop: 50 },
emptyText: { textAlign: 'center', marginTop: 15, fontSize: 14, fontWeight: '600' }
});

View File

@@ -1,32 +1,31 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient, processLock } from '@supabase/supabase-js'
import { AppState, Platform } from 'react-native'
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { AppState, Platform } from 'react-native';
import 'react-native-url-polyfill/auto';
const supabaseUrl = 'https://ssorfpctjeujolmtkfib.supabase.co'
const supabaseAnonKey = 'sb_publishable_SDocGprdYkUKi04FyfVqmA_Ykirp9cK'
// Substitui pelas tuas credenciais se necessário (estas são as que enviaste)
const supabaseUrl = 'https://ssorfpctjeujolmtkfib.supabase.co';
const supabaseAnonKey = 'sb_publishable_SDocGprdYkUKi04FyfVqmA_Ykirp9cK';
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
// No React Native usamos o AsyncStorage para persistir o login
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
lock: processLock,
// Nota: Removido o 'lock' e o 'lockAcquireTimeout' para evitar erros de TS
// O Supabase v2 no mobile já gere o fluxo de tokens de forma mais estável sem eles.
},
})
});
// Tells Supabase Auth to continuously refresh the session automatically
// if the app is in the foreground. When this is added, you will continue
// to receive `onAuthStateChange` events with the `TOKEN_REFRESHED` or
// `SIGNED_OUT` event if the user's session is terminated. This should
// only be registered once.
// Garante que o refresh do token só acontece quando a app está visível (Foreground)
if (Platform.OS !== "web") {
AppState.addEventListener('change', (state) => {
if (state === 'active') {
supabase.auth.startAutoRefresh()
supabase.auth.startAutoRefresh();
} else {
supabase.auth.stopAutoRefresh()
supabase.auth.stopAutoRefresh();
}
})
});
}

19
package-lock.json generated
View File

@@ -14,9 +14,11 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.91.0",
"base64-arraybuffer": "^1.0.2",
"expo": "~54.0.27",
"expo-constants": "~18.0.11",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
@@ -43,6 +45,7 @@
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/base64-arraybuffer": "^0.1.0",
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
@@ -3597,6 +3600,13 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/base64-arraybuffer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@types/base64-arraybuffer/-/base64-arraybuffer-0.1.0.tgz",
"integrity": "sha512-oyV0CGER7tX6OlfnLfGze0XbsA7tfRuTtsQ2JbP8K5KBUzc24yoYRD+0XjMRQgOejvZWeIbtkNaHlE8akzj4aQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4872,6 +4882,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",

View File

@@ -17,9 +17,11 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.91.0",
"base64-arraybuffer": "^1.0.2",
"expo": "~54.0.27",
"expo-constants": "~18.0.11",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
@@ -46,6 +48,7 @@
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/base64-arraybuffer": "^0.1.0",
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",