atualizacoes

This commit is contained in:
2026-03-12 10:42:11 +00:00
parent 7b6263af90
commit 7c5efa2695
4 changed files with 249 additions and 240 deletions

View File

@@ -2,16 +2,18 @@ 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
import { useRouter } from 'expo-router';
import * as Sharing from 'expo-sharing';
import { memo, useCallback, useMemo, useState } from 'react';
import {
Alert, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View
ActivityIndicator, Alert, 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
// Configuração PT
// Configuração PT (Mantida)
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'],
@@ -34,23 +36,6 @@ const getFeriadosMap = (ano: number) => {
[`${ano}-12-08`]: "Imaculada Conceição",
[`${ano}-12-25`]: "Natal"
};
const a = ano % 19, b = Math.floor(ano / 100), c = ano % 100;
const d = Math.floor(b / 4), e = b % 4, f_calc = Math.floor((b + 8) / 25);
const g = Math.floor((b - f_calc + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const mes = Math.floor((h + l - 7 * m + 114) / 31);
const dia = ((h + l - 7 * m + 114) % 31) + 1;
const pascoa = new Date(ano, mes - 1, dia);
const formatar = (dt: Date) => dt.toISOString().split('T')[0];
f[formatar(pascoa)] = "Páscoa";
f[formatar(new Date(pascoa.getTime() - 47 * 24 * 3600 * 1000))] = "Carnaval";
f[formatar(new Date(pascoa.getTime() - 2 * 24 * 3600 * 1000))] = "Sexta-feira Santa";
f[formatar(new Date(pascoa.getTime() + 60 * 24 * 3600 * 1000))] = "Corpo de Deus";
return f;
};
@@ -67,6 +52,7 @@ const AlunoHome = memo(() => {
const [faltasJustificadas, setFaltasJustificadas] = useState<Record<string, any>>({});
const [pdf, setPdf] = useState<any>(null);
const [editandoSumario, setEditandoSumario] = useState(false);
const [isLocating, setIsLocating] = useState(false); // Novo estado para loading
useFocusEffect(
useCallback(() => {
@@ -106,15 +92,12 @@ const AlunoHome = memo(() => {
const diaSemana = data.getDay();
const ehFimDeSemana = diaSemana === 0 || diaSemana === 6;
const foraDoIntervalo = selectedDate < configEstagio.inicio || selectedDate > configEstagio.fim;
// CORREÇÃO: Verifica se o dia selecionado é exatamente HOJE
const ehHoje = selectedDate === hojeStr;
const ehFuturo = selectedDate > hojeStr;
const nomeFeriado = feriadosMap[selectedDate];
return {
valida: !ehFimDeSemana && !foraDoIntervalo && !nomeFeriado,
// Só permite presença se for o dia atual
podeMarcarPresenca: ehHoje && !foraDoIntervalo && !nomeFeriado,
ehFuturo,
nomeFeriado
@@ -135,21 +118,78 @@ const AlunoHome = memo(() => {
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.");
const novas = { ...presencas, [selectedDate]: true };
setPresencas(novas);
await AsyncStorage.setItem('@presencas', JSON.stringify(novas));
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;
}
// 2. Obter Localização exata
let 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.");
const { error } = await supabase.from('presencas').upsert({
aluno_id: user.id,
data: selectedDate,
estado: 'presente',
lat: latitude,
lng: longitude
});
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.");
} finally {
setIsLocating(false);
}
};
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));
};
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] })
.match({ aluno_id: user.id, data: selectedDate });
}
await AsyncStorage.setItem('@sumarios', JSON.stringify(sumarios));
setEditandoSumario(false);
Alert.alert("Sucesso", "Sumário guardado!");
@@ -190,9 +230,9 @@ const AlunoHome = memo(() => {
<TouchableOpacity
style={[styles.btn, styles.btnPresenca, (!infoData.podeMarcarPresenca || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
onPress={handlePresenca}
disabled={!infoData.podeMarcarPresenca || !!presencas[selectedDate] || !!faltas[selectedDate]}
disabled={!infoData.podeMarcarPresenca || !!presencas[selectedDate] || !!faltas[selectedDate] || isLocating}
>
<Text style={styles.txtBtn}>Marcar Presença</Text>
{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]}

View File

@@ -1,8 +1,9 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Linking,
ActivityIndicator,
Alert,
Platform,
SafeAreaView,
ScrollView,
@@ -13,248 +14,205 @@ import {
View,
} from 'react-native';
import { useTheme } from '../../../themecontext';
import { supabase } from '../../lib/supabase';
interface Presenca {
id: string;
data: string;
estado: 'presente' | 'faltou';
localizacao?: { lat: number; lng: number };
hora?: string; // Adicionei hora para o design ficar mais rico
estado: string;
sumario: string | null;
lat?: number;
lng?: number;
}
/* DADOS EXEMPLO */
const presencasData: Presenca[] = [
{
data: '2024-01-10',
hora: '09:00',
estado: 'presente',
localizacao: { lat: 41.55, lng: -8.42 },
},
{
data: '2024-01-11',
hora: '09:15',
estado: 'faltou'
},
];
export default function CalendarioPresencas() {
export default function HistoricoPresencas() {
const router = useRouter();
const { nome } = useLocalSearchParams<{ nome: string }>();
const { alunoId, nome } = useLocalSearchParams();
const { isDarkMode } = useTheme();
const [presencas, setPresencas] = useState<Presenca[]>([]);
const [loading, setLoading] = useState(true);
const cores = useMemo(
() => ({
fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC',
card: isDarkMode ? '#1A1A1A' : '#FFFFFF',
texto: isDarkMode ? '#F8FAFC' : '#1E293B',
secundario: isDarkMode ? '#94A3B8' : '#64748B',
verde: '#10B981',
verdeSuave: 'rgba(16, 185, 129, 0.1)',
vermelho: '#EF4444',
vermelhoSuave: 'rgba(239, 68, 68, 0.1)',
azul: '#3B82F6',
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',
verde: '#10B981',
vermelho: '#EF4444',
}), [isDarkMode]);
const abrirMapa = (lat: number, lng: number) => {
const url = Platform.OS === 'ios'
? `maps://app?saddr=&daddr=${lat},${lng}`
: `google.navigation:q=${lat},${lng}`;
Linking.openURL(url);
};
useEffect(() => {
if (alunoId) fetchHistorico();
}, [alunoId]);
async function fetchHistorico() {
try {
setLoading(true);
const { data, error } = await supabase
.from('presencas')
.select('*')
.eq('aluno_id', alunoId)
.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.");
} finally {
setLoading(false);
}
}
return (
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
{/* StatusBar configurada para não sobrepor o conteúdo */}
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
translucent
backgroundColor="transparent"
/>
{/* HEADER SUPERIOR */}
<View style={[styles.headerFixed, { borderBottomColor: cores.borda }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={cores.texto} />
</TouchableOpacity>
<View style={styles.headerInfo}>
<Text style={[styles.title, { color: cores.texto }]}>{nome}</Text>
<Text style={[styles.subtitle, { color: cores.secundario }]}>Histórico de Registos</Text>
{/* Header com design idêntico ao anterior */}
<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>
<View style={{ alignItems: 'center' }}>
<Text style={[styles.title, { color: cores.texto }]}>Histórico</Text>
<Text style={[styles.subtitle, { color: cores.secundario }]}>{nome}</Text>
</View>
<TouchableOpacity onPress={fetchHistorico} style={styles.refreshBtn}>
<Ionicons name="refresh" size={22} color={cores.azul} />
</TouchableOpacity>
</View>
<View style={{ width: 40 }} />
</View>
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
{/* RESUMO RÁPIDO */}
<View style={styles.summaryRow}>
<View style={[styles.statBox, { backgroundColor: cores.card }]}>
<Text style={[styles.statLabel, { color: cores.secundario }]}>PRESENÇAS</Text>
<Text style={[styles.statValue, { color: cores.verde }]}>12</Text>
</View>
<View style={[styles.statBox, { backgroundColor: cores.card }]}>
<Text style={[styles.statLabel, { color: cores.secundario }]}>FALTAS</Text>
<Text style={[styles.statValue, { color: cores.vermelho }]}>2</Text>
</View>
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color={cores.azul} />
</View>
<Text style={[styles.sectionTitle, { color: cores.texto }]}>Atividade Recente</Text>
{presencasData.map((p, i) => {
const isPresente = p.estado === 'presente';
return (
<View key={i} style={styles.timelineItem}>
{/* LINHA LATERAL (TIMELINE) */}
<View style={styles.timelineLeft}>
<View style={[styles.dot, { backgroundColor: isPresente ? cores.verde : cores.vermelho }]} />
{i !== presencasData.length - 1 && <View style={[styles.line, { backgroundColor: cores.borda }]} />}
</View>
<View style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]}>
<View style={styles.cardHeader}>
<View>
<Text style={[styles.dateText, { color: cores.texto }]}>
{new Date(p.data).toLocaleDateString('pt-PT', { day: '2-digit', month: 'long' })}
</Text>
<Text style={[styles.hourText, { color: cores.secundario }]}>
Registado às {p.hora || '--:--'}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: isPresente ? cores.verdeSuave : cores.vermelhoSuave }]}>
<Text style={[styles.statusText, { color: isPresente ? cores.verde : cores.vermelho }]}>
{p.estado.toUpperCase()}
</Text>
</View>
</View>
<View style={[styles.cardDivider, { backgroundColor: cores.borda }]} />
<View style={styles.cardAction}>
{isPresente ? (
<TouchableOpacity
style={styles.actionButton}
onPress={() => abrirMapa(p.localizacao!.lat, p.localizacao!.lng)}
>
<Ionicons name="location-outline" size={18} color={cores.azul} />
<Text style={[styles.actionText, { color: cores.azul }]}>Ver Localização</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push('/Professor/Alunos/Faltas')}
>
<Ionicons name="alert-circle-outline" size={18} color={cores.vermelho} />
<Text style={[styles.actionText, { color: cores.vermelho }]}>Justificar Falta</Text>
</TouchableOpacity>
)}
</View>
</View>
) : (
<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>
</View>
);
})}
</ScrollView>
) : (
presencas.map((item) => {
const isPresente = item.estado === 'presente';
return (
<View key={item.id} 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>
<View style={styles.info}>
<Text style={[styles.dateText, { color: cores.texto }]}>
{new Date(item.data).toLocaleDateString('pt-PT', { day: '2-digit', month: 'long', year: 'numeric' })}
</Text>
<View style={styles.statusBadge}>
<View style={[styles.dot, { backgroundColor: isPresente ? cores.verde : cores.vermelho }]} />
<Text style={[styles.subText, { color: cores.secundario }]}>
{item.estado.toUpperCase()}
</Text>
</View>
</View>
{item.lat && (
<Ionicons name="location-sharp" size={18} color={cores.azul} />
)}
</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."}
</Text>
</View>
</View>
);
})
)}
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
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
},
headerFixed: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 15,
paddingVertical: 15,
borderBottomWidth: 1,
marginTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0,
headerFixed: {
paddingHorizontal: 20,
paddingBottom: 10
},
backBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 70
},
headerInfo: {
alignItems: 'center',
},
title: { fontSize: 18, fontWeight: '800' },
subtitle: { fontSize: 12, fontWeight: '500' },
scrollContainer: { padding: 20 },
summaryRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 30,
},
statBox: {
flex: 1,
padding: 15,
borderRadius: 20,
alignItems: 'center',
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 },
card: {
borderRadius: 20,
padding: 16,
marginBottom: 15,
borderWidth: 1,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 10,
shadowRadius: 5
},
statLabel: { fontSize: 9, fontWeight: '800', letterSpacing: 1 },
statValue: { fontSize: 22, fontWeight: '900', marginTop: 4 },
sectionTitle: { fontSize: 16, fontWeight: '800', marginBottom: 20 },
timelineItem: {
flexDirection: 'row',
cardHeader: { flexDirection: 'row', alignItems: 'center' },
iconBox: {
width: 44,
height: 44,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center'
},
timelineLeft: {
width: 30,
alignItems: 'center',
info: { flex: 1, marginLeft: 15 },
dateText: { fontSize: 16, 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,
},
dot: {
width: 12,
height: 12,
borderRadius: 6,
zIndex: 2,
sumarioLabel: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
marginBottom: 5,
letterSpacing: 0.5
},
line: {
width: 2,
flex: 1,
marginTop: -5,
},
card: {
flex: 1,
borderRadius: 20,
padding: 16,
marginBottom: 20,
borderWidth: 1,
marginLeft: 10,
elevation: 3,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 8,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
dateText: { fontSize: 15, fontWeight: '700' },
hourText: { fontSize: 12, marginTop: 2 },
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
statusText: { fontSize: 9, fontWeight: '900' },
cardDivider: {
height: 1,
marginVertical: 12,
},
cardAction: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
actionText: { fontSize: 13, fontWeight: '700' },
sumarioText: { fontSize: 13, lineHeight: 18, opacity: 0.9 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
empty: { alignItems: 'center', marginTop: 60 },
emptyText: { marginTop: 15, fontSize: 14, fontWeight: '600', textAlign: 'center' },
});

10
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.10",
"expo-location": "~19.0.8",
"expo-router": "~6.0.17",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.12",
@@ -6576,6 +6577,15 @@
"react-native": "*"
}
},
"node_modules/expo-location": {
"version": "19.0.8",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz",
"integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",

View File

@@ -24,6 +24,7 @@
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.10",
"expo-location": "~19.0.8",
"expo-router": "~6.0.17",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.12",