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:
@@ -39,6 +39,7 @@ class ChatMemoryService {
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
'selectedMaterialIds': selectedMaterialIds,
|
||||
'messageCount': 0,
|
||||
'hasUserMessage': false,
|
||||
});
|
||||
|
||||
_currentConversationId = conversationRef.id;
|
||||
@@ -98,19 +99,24 @@ class ChatMemoryService {
|
||||
.orderBy('updatedAt', descending: true)
|
||||
.get();
|
||||
|
||||
final conversations = snapshot.docs.map((doc) {
|
||||
final data = doc.data();
|
||||
return {
|
||||
'id': doc.id,
|
||||
'title': data['title'] as String? ?? 'Sem título',
|
||||
'createdAt': data['createdAt'] as Timestamp?,
|
||||
'updatedAt': data['updatedAt'] as Timestamp?,
|
||||
'selectedMaterialIds':
|
||||
(data['selectedMaterialIds'] as List<dynamic>?)?.cast<String>() ??
|
||||
[],
|
||||
'messageCount': data['messageCount'] as int? ?? 0,
|
||||
};
|
||||
}).toList();
|
||||
final conversations = snapshot.docs
|
||||
.map((doc) {
|
||||
final data = doc.data();
|
||||
return {
|
||||
'id': doc.id,
|
||||
'title': data['title'] as String? ?? 'Sem título',
|
||||
'createdAt': data['createdAt'] as Timestamp?,
|
||||
'updatedAt': data['updatedAt'] as Timestamp?,
|
||||
'selectedMaterialIds':
|
||||
(data['selectedMaterialIds'] as List<dynamic>?)
|
||||
?.cast<String>() ??
|
||||
[],
|
||||
'messageCount': data['messageCount'] as int? ?? 0,
|
||||
'hasUserMessage': data['hasUserMessage'] as bool? ?? false,
|
||||
};
|
||||
})
|
||||
.where((conv) => (conv['hasUserMessage'] as bool) == true)
|
||||
.toList();
|
||||
|
||||
Logger.info('Retrieved ${conversations.length} conversations');
|
||||
return conversations;
|
||||
@@ -193,15 +199,22 @@ class ChatMemoryService {
|
||||
.add(messageData);
|
||||
|
||||
// Update conversation metadata
|
||||
final updateData = <String, dynamic>{
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
'messageCount': FieldValue.increment(1),
|
||||
};
|
||||
|
||||
// Set hasUserMessage to true if this is a user message
|
||||
if (role == 'user') {
|
||||
updateData['hasUserMessage'] = true;
|
||||
}
|
||||
|
||||
await _firestore
|
||||
.collection('userChats')
|
||||
.doc(user.uid)
|
||||
.collection('conversations')
|
||||
.doc(convId)
|
||||
.update({
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
'messageCount': FieldValue.increment(1),
|
||||
});
|
||||
.update(updateData);
|
||||
|
||||
Logger.info(
|
||||
'Message saved to Firestore: role=$role, conversation=$convId',
|
||||
@@ -281,6 +294,7 @@ class ChatMemoryService {
|
||||
(data?['selectedMaterialIds'] as List<dynamic>?)?.cast<String>() ??
|
||||
[],
|
||||
'messageCount': data?['messageCount'] as int? ?? 0,
|
||||
'hasUserMessage': data?['hasUserMessage'] as bool? ?? false,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error('Error getting conversation: $e');
|
||||
|
||||
@@ -120,8 +120,10 @@ class _StudentAchievementsPageState extends State<StudentAchievementsPage> {
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 52),
|
||||
// Content
|
||||
Expanded(
|
||||
child: _loading
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
@@ -10,6 +11,17 @@ import 'l10n/app_localizations.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Set system UI overlay to make status bar transparent and remove padding
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
|
||||
// Initialize Firebase
|
||||
await FirebaseService.initialize();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user