Melhoria no funcionamento do histórico, Os nomes no dashboard do aluno carregam a primeira vez e ficam salvos para n ficarem sempre a carregar quando se volta ao dashboard, removi o butão de novo chat na interface de introdução da IA, mudei a aparencia dessa introdução e do histórico

This commit is contained in:
2026-05-23 16:20:27 +01:00
parent 7ee262f4c7
commit 895ce64c6f
22 changed files with 1330 additions and 582 deletions

View File

@@ -120,8 +120,10 @@ class _StudentAchievementsPageState extends State<StudentAchievementsPage> {
),
),
child: SafeArea(
top: false,
child: Column(
children: [
const SizedBox(height: 52),
// Content
Expanded(
child: _loading

View File

@@ -3,6 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/logger.dart';
class ChatHistoryPage extends StatefulWidget {
@@ -20,6 +21,9 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
String _selectedDateFilter = 'all'; // all, today, yesterday, week, month
final Set<String> _selectedConversationIds = {};
bool _isSelectionMode = false;
final Map<String, String> _materialNamesCache = {}; // materialId -> fileName
String? _source; // 'chat' or 'intro'
String? _previousConversationId;
@override
void initState() {
@@ -28,6 +32,73 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
_searchController.addListener(_filterConversations);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Extract source and previous conversation ID from URL parameters
final uri = GoRouterState.of(context).uri;
_source = uri.queryParameters['source'];
_previousConversationId = uri.queryParameters['conversationId'];
}
void _handleBackNavigation() {
if (_source == 'chat' && _previousConversationId != null) {
// Go back to the previous conversation
context.go('/ai-tutor/$_previousConversationId');
} else if (_source == 'chat') {
// Go to new chat (no previous conversation)
context.go('/ai-tutor');
} else if (_source == 'intro') {
// Go back to intro screen
context.go('/ai-tutor');
} else {
// Default: go to intro screen (from dashboard or other sources)
context.go('/ai-tutor');
}
}
/// Fetch material names for given material IDs
Future<void> _loadMaterialNames(List<String> materialIds) async {
if (materialIds.isEmpty) return;
final uncachedIds = materialIds
.where((id) => !_materialNamesCache.containsKey(id))
.toList();
if (uncachedIds.isEmpty) return;
try {
final batches = <Future<QuerySnapshot>>[];
for (int i = 0; i < uncachedIds.length; i += 10) {
final batch = uncachedIds.skip(i).take(10).toList();
batches.add(
FirebaseFirestore.instance
.collection('materials')
.where(FieldPath.documentId, whereIn: batch)
.get(),
);
}
final results = await Future.wait(batches);
for (final snapshot in results) {
for (final doc in snapshot.docs) {
final data = doc.data() as Map<String, dynamic>?;
if (data != null) {
final fileName = data['fileName'] as String?;
if (fileName != null) {
_materialNamesCache[doc.id] = fileName;
}
}
}
}
if (mounted) {
setState(() {});
}
} catch (e) {
Logger.error('Error loading material names: $e');
}
}
@override
void dispose() {
_searchController.dispose();
@@ -38,6 +109,17 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
setState(() => _isLoading = true);
try {
final conversations = await ChatMemoryService.getConversations();
// Collect all material IDs from all conversations
final allMaterialIds = <String>[];
for (final conv in conversations) {
final materialIds = conv['selectedMaterialIds'] as List<String>? ?? [];
allMaterialIds.addAll(materialIds);
}
// Load material names
await _loadMaterialNames(allMaterialIds);
if (mounted) {
setState(() {
_conversations = conversations;
@@ -269,159 +351,302 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final orangeAccent = isDark
? const Color(0xFFC47A2A)
: const Color(0xFFF68D2D);
final lightOrange = isDark
? const Color(0xFFD89035)
: const Color(0xFFF7A960);
return Scaffold(
backgroundColor: cs.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: cs.onSurface),
onPressed: () => context.go('/student-dashboard'),
),
title: Text(
_isSelectionMode
? '${_selectedConversationIds.length} selecionadas'
: 'Histórico de Conversas',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_handleBackNavigation();
},
child: Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
cs.surfaceVariant,
orangeAccent.withValues(alpha: 0.3),
cs.surfaceContainerLowest,
]
: [
cs.primary.withValues(alpha: 0.08),
orangeAccent.withValues(alpha: 0.05),
cs.surfaceContainerLowest,
],
stops: const [0.0, 0.4, 1.0],
),
),
),
actions: [
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: _selectedConversationIds.isEmpty
? null
: _deleteSelectedConversations,
)
else
IconButton(
icon: Icon(Icons.checklist, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
if (!_isSelectionMode)
IconButton(
icon: Icon(Icons.refresh, color: cs.onSurface),
onPressed: _loadConversations,
),
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.close, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
],
),
body: Column(
children: [
// Search and filters
Padding(
padding: const EdgeInsets.all(16),
child: SafeArea(
top: false,
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar conversas...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.3),
),
),
filled: true,
fillColor: cs.surface,
// Custom app bar
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 15,
),
),
const SizedBox(height: 12),
// Date filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDateFilterChip('Todas', 'all', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Hoje', 'today', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Ontem', 'yesterday', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Última semana', 'week', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Último mês', 'month', cs),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 20,
),
onPressed: _handleBackNavigation,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isSelectionMode
? '${_selectedConversationIds.length} selecionadas'
: 'Histórico',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (_isSelectionMode)
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
onPressed: _selectedConversationIds.isEmpty
? null
: _deleteSelectedConversations,
)
else
IconButton(
icon: const Icon(
Icons.checklist,
color: Colors.white,
),
onPressed: _toggleSelectionMode,
),
if (!_isSelectionMode)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white),
onPressed: _loadConversations,
),
if (_isSelectionMode)
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: _toggleSelectionMode,
),
],
),
),
// Search and filters
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar conversas...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: orangeAccent.withValues(alpha: 0.3),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: orangeAccent.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(
color: AppColors.primaryOrange,
width: 2,
),
),
filled: true,
fillColor: cs.surface,
),
),
const SizedBox(height: 12),
// Date filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDateFilterChip(
'Todas',
'all',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Hoje',
'today',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Ontem',
'yesterday',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Semana',
'week',
cs,
orangeAccent,
),
const SizedBox(width: 8),
_buildDateFilterChip(
'Mês',
'month',
cs,
orangeAccent,
),
],
),
),
],
),
),
// Conversation list
Expanded(
child: _isLoading
? Center(
child: CircularProgressIndicator(color: orangeAccent),
)
: _filteredConversations.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(
alpha: 0.2,
),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.chat_bubble_outline,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 20),
Text(
_conversations.isEmpty
? 'Sem conversas ainda'
: 'Nenhuma conversa encontrada',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
_conversations.isEmpty
? 'Começa uma conversa com o Vico!'
: 'Tenta ajustar os filtros',
style: TextStyle(
color: cs.onSurfaceVariant.withValues(
alpha: 0.7,
),
fontSize: 14,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _filteredConversations.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final conversation = _filteredConversations[index];
return _buildConversationCard(
conversation,
cs,
orangeAccent,
lightOrange,
);
},
),
),
],
),
),
// Conversation list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredConversations.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
Text(
_conversations.isEmpty
? 'Sem conversas ainda'
: 'Nenhuma conversa encontrada',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
_conversations.isEmpty
? 'Começa uma conversa com o Vico!'
: 'Tenta ajustar os filtros',
style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 14,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _filteredConversations.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final conversation = _filteredConversations[index];
return _buildConversationCard(conversation, cs);
},
),
),
],
),
),
);
}
Widget _buildDateFilterChip(String label, String value, ColorScheme cs) {
Widget _buildDateFilterChip(
String label,
String value,
ColorScheme cs,
Color orangeAccent,
) {
final isSelected = _selectedDateFilter == value;
return FilterChip(
label: Text(label),
@@ -433,13 +658,13 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
_filterConversations();
},
backgroundColor: cs.surface,
selectedColor: cs.primary.withValues(alpha: 0.2),
selectedColor: orangeAccent.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? cs.primary : cs.onSurface,
color: isSelected ? orangeAccent : cs.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
side: BorderSide(
color: isSelected ? cs.primary : cs.outline.withValues(alpha: 0.3),
color: isSelected ? orangeAccent : cs.outline.withValues(alpha: 0.3),
),
);
}
@@ -447,6 +672,8 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
Widget _buildConversationCard(
Map<String, dynamic> conversation,
ColorScheme cs,
Color orangeAccent,
Color lightOrange,
) {
final isSelected = _selectedConversationIds.contains(conversation['id']);
@@ -476,22 +703,25 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
context.go('/ai-tutor/${conversation['id']}');
}
},
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected ? cs.primary.withValues(alpha: 0.1) : cs.surface,
borderRadius: BorderRadius.circular(16),
color: isSelected
? orangeAccent.withValues(alpha: 0.1)
: cs.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? cs.primary
: cs.outline.withValues(alpha: 0.15),
? orangeAccent
: orangeAccent.withValues(alpha: 0.2),
width: isSelected ? 2 : 1.5,
),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
color: orangeAccent.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
@@ -507,6 +737,12 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
value: isSelected,
onChanged: (_) =>
_toggleConversationSelection(conversation['id']),
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return orangeAccent;
}
return null;
}),
),
)
else
@@ -515,12 +751,18 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
cs.primary,
cs.primary.withValues(alpha: 0.7),
],
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.chat_bubble,
@@ -540,7 +782,7 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
@@ -590,26 +832,35 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
runSpacing: 6,
children: (conversation['selectedMaterialIds'] as List)
.take(3)
.map<Widget>(
(id) => Container(
.map<Widget>((id) {
final materialId = id as String;
final displayName =
_materialNamesCache[materialId] ?? materialId;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.1),
color: lightOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: lightOrange.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
id.length > 15 ? '${id.substring(0, 15)}...' : id,
displayName.length > 20
? '${displayName.substring(0, 20)}...'
: displayName,
style: TextStyle(
color: cs.primary,
color: lightOrange,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
)
);
})
.toList(),
),
),

View File

@@ -7,6 +7,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/services/materials_rag_service.dart';
import '../../../../core/services/rag_ai_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/logger.dart';
import '../../../materials/presentation/pages/pdf_viewer_page.dart';
@@ -74,6 +75,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Future<void> _loadConversation(String conversationId) async {
try {
Logger.info('Loading conversation: $conversationId');
final conversation = await ChatMemoryService.getConversation(
conversationId,
);
@@ -98,19 +100,32 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
if (mounted) {
setState(() {
_selectedMaterialIds = materialIds;
_materialsConfirmed = materialIds.isNotEmpty;
// Always set to true when loading from history to allow chat access
_materialsConfirmed = true;
});
}
} else {
// No materials in conversation, but still allow chat access
if (mounted) {
setState(() {
_materialsConfirmed = true;
});
}
}
} catch (e) {
Logger.error('Error loading material IDs: $e');
// Continue without materials rather than crash
// Continue without materials but allow chat access
if (mounted) {
setState(() {
_materialsConfirmed = true;
});
}
}
// Load messages from Firestore with safe casting
final loadedMessages = messages.map((msg) {
try {
return {
return <String, dynamic>{
'content': msg['content']?.toString() ?? '',
'isUser': msg['role']?.toString() == 'user',
'timestamp': msg['createdAt'] is Timestamp
@@ -120,7 +135,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
} catch (e) {
Logger.error('Error mapping message: $e');
// Return a safe fallback message
return {
return <String, dynamic>{
'content': '[Mensagem indisponível]',
'isUser': false,
'timestamp': DateTime.now(),
@@ -130,9 +145,14 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
if (mounted) {
setState(() {
_messages = loadedMessages;
_messages.clear();
_messages.addAll(loadedMessages);
_isLoading = false; // Ensure loading state is reset
});
_scrollToBottom();
Logger.info(
'Conversation loaded. _messages count: ${_messages.length}, _isLoading: $_isLoading, _materialsConfirmed: $_materialsConfirmed',
);
}
} catch (e) {
Logger.error('Error loading conversation: $e');
@@ -189,9 +209,11 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
onPressed: () => context.go('/student-dashboard'),
@@ -205,13 +227,15 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.school,
color: Colors.white,
size: 22,
child: Padding(
padding: const EdgeInsets.all(6),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
),
),
const SizedBox(width: 10),
@@ -282,49 +306,63 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
),
),
IconButton(
onPressed: () => context.go('/chat-history'),
onPressed: () {
final currentId =
ChatMemoryService.currentConversationId;
// If materials are confirmed, we're in chat mode
final source = _materialsConfirmed ? 'chat' : 'intro';
if (currentId != null) {
context.go(
'/chat-history?source=$source&conversationId=$currentId',
);
} else {
context.go('/chat-history?source=$source');
}
},
icon: const Icon(
Icons.history,
color: Colors.white,
size: 20,
),
),
IconButton(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nova conversa'),
content: const Text(
'Isto vai limpar o chat atual e guardá-lo no histórico. Continuar?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
if (_materialsConfirmed)
IconButton(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nova conversa'),
content: const Text(
'Isto vai limpar o chat atual e guardá-lo no histórico. Continuar?',
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
actions: [
TextButton(
onPressed: () =>
Navigator.pop(context, false),
child: const Text('Cancelar'),
),
child: const Text('Limpar'),
),
],
),
);
if (confirmed == true) {
ChatMemoryService.setCurrentConversationId(null);
context.go('/ai-tutor');
}
},
icon: const Icon(
Icons.add_comment,
color: Colors.white,
size: 20,
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Limpar'),
),
],
),
);
if (confirmed == true) {
ChatMemoryService.setCurrentConversationId(null);
context.go('/ai-tutor');
}
},
icon: const Icon(
Icons.add_comment,
color: Colors.white,
size: 20,
),
tooltip: 'Nova conversa',
),
tooltip: 'Nova conversa',
),
const SizedBox(width: 4),
],
),
@@ -342,92 +380,300 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
// ── Intro screen shown before any material is selected ──────────────────
Widget _buildIntroScreen(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final orangeAccent = isDark
? const Color(0xFFC47A2A)
: const Color(0xFFF68D2D);
final lightOrange = isDark
? const Color(0xFFD89035)
: const Color(0xFFF7A960);
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: cs.primary.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: const Icon(Icons.school, color: Colors.white, size: 44),
),
const SizedBox(height: 24),
Text(
'Olá! Sou o Vico',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
const SizedBox(height: 12),
Text(
'O teu assistente de estudo inteligente.\nRespondo com base nos materiais do teu professor, ajudo-te a perceber conceitos e a preparares-te para os testes.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: cs.onSurfaceVariant,
height: 1.55,
),
),
const SizedBox(height: 36),
_availableMaterials.isEmpty
? Column(
children: [
CircularProgressIndicator(color: cs.primary),
const SizedBox(height: 16),
Text(
'A carregar materiais\u2026',
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant,
top: false,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
cs.surfaceVariant,
orangeAccent.withValues(alpha: 0.3),
cs.surfaceContainerLowest,
]
: [
cs.primary.withValues(alpha: 0.08),
orangeAccent.withValues(alpha: 0.05),
cs.surfaceContainerLowest,
],
stops: const [0.0, 0.4, 1.0],
),
),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(
left: 24,
right: 24,
bottom: 24,
top: 52,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Avatar with animated-like glow (teal to orange gradient)
Container(
width: 140,
height: 140,
decoration: BoxDecoration(
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.3),
blurRadius: 24,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
),
),
),
],
)
: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _showMaterialsPicker(allowEmpty: true),
icon: const Icon(Icons.folder_open_rounded),
label: const Text(
'Escolher materiais para estudar',
style: TextStyle(fontSize: 15),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
const SizedBox(height: 28),
// Title with orange accent
RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: 'Olá! Sou o ',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: cs.onSurface,
),
),
TextSpan(
text: 'Vico',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: orangeAccent,
),
),
],
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
const SizedBox(height: 12),
// Subtitle
Text(
'O teu assistente de estudo inteligente',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: cs.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
// Description card with orange border accent
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: orangeAccent.withValues(alpha: 0.2),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
_buildFeatureRow(
icon: Icons.menu_book,
text:
'Respondo com base nos materiais do teu professor',
cs: cs,
iconColor: cs.primary,
),
const SizedBox(height: 14),
_buildFeatureRow(
icon: Icons.psychology,
text: 'Ajudo-te a perceber conceitos complexos',
cs: cs,
iconColor: orangeAccent,
),
const SizedBox(height: 14),
_buildFeatureRow(
icon: Icons.quiz,
text: 'Prepara-te para testes e exames',
cs: cs,
iconColor: lightOrange,
),
],
),
),
const Spacer(flex: 3),
// Action button
_availableMaterials.isEmpty
? Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: orangeAccent,
strokeWidth: 2.5,
),
const SizedBox(height: 16),
Text(
'A carregar materiais…',
style: TextStyle(
fontSize: 14,
color: cs.onSurfaceVariant,
),
),
],
)
: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, orangeAccent],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: orangeAccent.withValues(
alpha: 0.3,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: FilledButton.icon(
onPressed: () =>
_showMaterialsPicker(allowEmpty: true),
icon: const Icon(
Icons.folder_open_rounded,
size: 22,
),
label: const Text(
'Escolher materiais para estudar',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
),
),
const SizedBox(height: 16),
// Secondary hint with orange accent
RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: 'Seleciona um ou mais materiais para ',
style: TextStyle(
fontSize: 13,
color: cs.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
TextSpan(
text: 'começar',
style: TextStyle(
fontSize: 13,
color: orangeAccent,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 8),
],
),
),
],
),
),
);
},
),
),
);
}
Widget _buildFeatureRow({
required IconData icon,
required String text,
required ColorScheme cs,
Color? iconColor,
}) {
final effectiveIconColor = iconColor ?? cs.primary;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: effectiveIconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: effectiveIconColor, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
text,
style: TextStyle(
fontSize: 14,
color: cs.onSurfaceVariant,
height: 1.4,
),
),
),
),
],
);
}
// ── Chat body (messages + input) ──────────────────────────────────────────
Widget _buildChatBody(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -665,9 +911,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
width: 32,
height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.7)],
),
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
@@ -677,7 +921,10 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
),
],
),
child: const Icon(Icons.school, color: Colors.white, size: 18),
child: Padding(
padding: const EdgeInsets.all(6),
child: Image.asset('assets/images/epvc.png', fit: BoxFit.contain),
),
);
}
@@ -956,6 +1203,9 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
onSubmitted: (_) => _handleSendMessage(),
textInputAction: TextInputAction.send,
onChanged: (value) {
Logger.info(
'TextField changed. Text: "$value", isEmpty: ${value.isEmpty}',
);
setState(() {});
},
),
@@ -1457,6 +1707,9 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
if (_messageController.text.trim().isEmpty) return;
final userMessage = _messageController.text.trim();
Logger.info(
'Attempting to send message. currentConversationId: ${ChatMemoryService.currentConversationId}',
);
// Update conversation title if it's still the default "Nova conversa"
final currentId = ChatMemoryService.currentConversationId;
@@ -1473,7 +1726,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
// Add user message
setState(() {
_messages.add({
_messages.add(<String, dynamic>{
'content': userMessage,
'isUser': true,
'timestamp': DateTime.now(),
@@ -1506,7 +1759,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Logger.info('Ollama response received: $preview...');
setState(() {
_messages.add({
_messages.add(<String, dynamic>{
'content': replyText,
'isUser': false,
'timestamp': DateTime.now(),
@@ -1520,7 +1773,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
'Desculpe, ocorreu um erro ao processar a pergunta. Tente novamente.';
setState(() {
_messages.add({
_messages.add(<String, dynamic>{
'content': aiResponse,
'isUser': false,
'timestamp': DateTime.now(),

View File

@@ -108,15 +108,22 @@ class _AnalyticsPageState extends State<AnalyticsPage>
),
),
child: SafeArea(
top: false,
bottom: false,
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.only(
left: 24,
right: 24,
bottom: 28,
top: 52,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(

View File

@@ -228,9 +228,15 @@ class _LoginPageState extends State<LoginPage> {
),
),
child: SafeArea(
top: false,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Form(
key: _formKey,
child: Column(

View File

@@ -38,8 +38,14 @@ class _RoleSelectionPageState extends State<RoleSelectionPage> {
// Main content
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
children: [
const Spacer(flex: 1),

View File

@@ -257,9 +257,15 @@ class _SignupPageState extends State<SignupPage> {
),
),
child: SafeArea(
top: false,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Form(
key: _formKey,
child: Column(

View File

@@ -194,12 +194,19 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
Container(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 20.0,
top: 52.0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(

View File

@@ -11,6 +11,14 @@ import '../widgets/profile_section_widget.dart';
class StudentDashboardPage extends StatefulWidget {
const StudentDashboardPage({super.key});
/// Clear the cached user name (call when name is updated in settings)
static void clearCachedUserName() {
_cachedUserName = null;
}
/// Cached user name to prevent flickering
static String? _cachedUserName;
@override
State<StudentDashboardPage> createState() => _StudentDashboardPageState();
}
@@ -21,7 +29,12 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
@override
void initState() {
super.initState();
_checkRoleAndLoadData();
// Use cached name if available, otherwise load data
if (StudentDashboardPage._cachedUserName != null) {
_userName = StudentDashboardPage._cachedUserName!;
} else {
_checkRoleAndLoadData();
}
}
Future<void> _checkRoleAndLoadData() async {
@@ -75,6 +88,7 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
print('DEBUG: Final displayName to use: "$displayName"');
setState(() {
_userName = displayName;
StudentDashboardPage._cachedUserName = displayName;
});
}
}
@@ -99,14 +113,21 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with logout and settings
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(

View File

@@ -94,14 +94,21 @@ class _TeacherDashboardPageState extends State<TeacherDashboardPage> {
),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.only(
left: 24.0,
right: 24.0,
bottom: 28.0,
top: 52.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with logout and settings
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(

View File

@@ -17,12 +17,22 @@ class ProgressHeroWidget extends StatefulWidget {
}
class _ProgressHeroWidgetState extends State<ProgressHeroWidget> {
UserStats? _cachedUserStats;
bool _isFirstLoad = true;
@override
Widget build(BuildContext context) {
return FutureBuilder<UserStats?>(
future: _loadUserStats(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// Show cached data while loading to prevent flickering
if (snapshot.connectionState == ConnectionState.waiting &&
_cachedUserStats != null) {
return _buildContent(_cachedUserStats);
}
if (snapshot.connectionState == ConnectionState.waiting &&
_isFirstLoad) {
return _buildLoadingState();
}
@@ -31,6 +41,10 @@ class _ProgressHeroWidgetState extends State<ProgressHeroWidget> {
}
final userStats = snapshot.data;
if (userStats != null) {
_cachedUserStats = userStats;
_isFirstLoad = false;
}
return _buildContent(userStats);
},
);

View File

@@ -107,6 +107,21 @@ class QuickAccessWidget extends StatelessWidget {
titleFontSize: _titleFontSize,
subtitleFontSize: _subtitleFontSize,
padding: _cardPadding,
leadingIcon: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
),
),
onTap: () => context.go('/ai-tutor'),
)
.animate()
@@ -227,6 +242,7 @@ class QuickAccessWidget extends StatelessWidget {
subtitle: 'Assistente de estudos',
icon: Icons.psychology,
useGradient: true,
useCustomIcon: true,
onTap: () {
Navigator.pop(context);
context.go('/ai-tutor');
@@ -330,29 +346,47 @@ class QuickAccessWidget extends StatelessWidget {
),
),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: item.useGradient
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.1)
: (item.iconColor ??
Theme.of(
context,
).colorScheme.secondary)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: item.useGradient
? Theme.of(context).colorScheme.primary
: (item.iconColor ??
Theme.of(context).colorScheme.secondary),
size: 24,
),
),
leading: item.useCustomIcon
? Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF9EEE8),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset(
'assets/images/epvc.png',
fit: BoxFit.contain,
),
),
)
: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: item.useGradient
? Theme.of(
context,
).colorScheme.primary.withOpacity(0.1)
: (item.iconColor ??
Theme.of(
context,
).colorScheme.secondary)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: item.useGradient
? Theme.of(context).colorScheme.primary
: (item.iconColor ??
Theme.of(
context,
).colorScheme.secondary),
size: 24,
),
),
title: Text(
item.title,
style: TextStyle(
@@ -395,6 +429,7 @@ class _QuickAccessItem {
final IconData icon;
final bool useGradient;
final Color? iconColor;
final bool useCustomIcon;
final VoidCallback onTap;
_QuickAccessItem({
@@ -403,6 +438,7 @@ class _QuickAccessItem {
required this.icon,
this.useGradient = false,
this.iconColor,
this.useCustomIcon = false,
required this.onTap,
});
}

View File

@@ -326,11 +326,18 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
child: Column(
children: [
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 20,
top: 52,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: cs.onSurface),

View File

@@ -61,8 +61,14 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
child: Column(
children: [
SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 20,
top: 52,
),
decoration: BoxDecoration(
color: cs.surface.withOpacity(0.8),
boxShadow: [
@@ -74,6 +80,7 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: cs.onSurface),

View File

@@ -200,117 +200,126 @@ class _TeacherMaterialsPageState extends State<TeacherMaterialsPage> {
Widget _buildClassTab({required String classId}) {
return SafeArea(
top: false,
child: Stack(
children: [
StreamBuilder<QuerySnapshot>(
stream: _getMaterialsStream(classId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF82C9BD)),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 48,
),
const SizedBox(height: 16),
Text(
'Erro ao carregar materiais:\n${snapshot.error}',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
),
],
),
);
}
final materials = snapshot.data?.docs ?? [];
// Sort by createdAt descending on client side
materials.sort((a, b) {
final aData = a.data() as Map<String, dynamic>?;
final bData = b.data() as Map<String, dynamic>?;
final aTime = aData?['createdAt'] as Timestamp?;
final bTime = bData?['createdAt'] as Timestamp?;
if (aTime == null && bTime == null) return 0;
if (aTime == null) return 1;
if (bTime == null) return -1;
return bTime.compareTo(aTime);
});
if (materials.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.folder_open,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'Nenhum material enviado ainda.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'Os materiais enviados aparecerão aqui.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: materials.length,
itemBuilder: (context, index) {
final material =
materials[index].data() as Map<String, dynamic>;
final fileName = material['fileName'] ?? 'Ficheiro sem nome';
final createdAt = material['createdAt'] as Timestamp?;
final extension = path.extension(fileName).toLowerCase();
final fileType = extension == '.pdf'
? 'pdf'
: (extension == '.jpg' ||
extension == '.jpeg' ||
extension == '.png')
? 'image'
: 'other';
final docId = materials[index].id;
final url = material['url'] as String?;
return _buildMaterialCard(
docId: docId,
fileName: fileName,
fileType: fileType,
createdAt: createdAt,
url: url,
classId: classId,
Padding(
padding: const EdgeInsets.only(top: 52.0),
child: StreamBuilder<QuerySnapshot>(
stream: _getMaterialsStream(classId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF82C9BD)),
);
},
);
},
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 48,
),
const SizedBox(height: 16),
Text(
'Erro ao carregar materiais:\n${snapshot.error}',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
),
],
),
);
}
final materials = snapshot.data?.docs ?? [];
// Sort by createdAt descending on client side
materials.sort((a, b) {
final aData = a.data() as Map<String, dynamic>?;
final bData = b.data() as Map<String, dynamic>?;
final aTime = aData?['createdAt'] as Timestamp?;
final bTime = bData?['createdAt'] as Timestamp?;
if (aTime == null && bTime == null) return 0;
if (aTime == null) return 1;
if (bTime == null) return -1;
return bTime.compareTo(aTime);
});
if (materials.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.folder_open,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'Nenhum material enviado ainda.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'Os materiais enviados aparecerão aqui.',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: materials.length,
itemBuilder: (context, index) {
final material =
materials[index].data() as Map<String, dynamic>;
final fileName =
material['fileName'] ?? 'Ficheiro sem nome';
final createdAt = material['createdAt'] as Timestamp?;
final extension = path.extension(fileName).toLowerCase();
final fileType = extension == '.pdf'
? 'pdf'
: (extension == '.jpg' ||
extension == '.jpeg' ||
extension == '.png')
? 'image'
: 'other';
final docId = materials[index].id;
final url = material['url'] as String?;
return _buildMaterialCard(
docId: docId,
fileName: fileName,
fileType: fileType,
createdAt: createdAt,
url: url,
classId: classId,
);
},
);
},
),
),
Positioned(
right: 16,

View File

@@ -30,12 +30,19 @@ class HelpPage extends StatelessWidget {
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 20.0,
top: 52.0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../dashboard/presentation/pages/student_dashboard_page.dart';
/// Profile edit page for settings
class ProfileEditPage extends ConsumerStatefulWidget {
@@ -57,6 +58,9 @@ class _ProfileEditPageState extends ConsumerState<ProfileEditPage> {
await user.updateDisplayName(_nameController.text);
await user.reload();
// Clear cached user name so dashboard will reload with new name
StudentDashboardPage.clearCachedUserName();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -111,12 +115,19 @@ class _ProfileEditPageState extends ConsumerState<ProfileEditPage> {
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 20.0,
top: 52.0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),

View File

@@ -55,12 +55,19 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 20.0,
top: 52.0,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(