|
|
|
|
@@ -1,51 +1,746 @@
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
|
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
|
|
|
import '../../../../core/services/materials_rag_service.dart';
|
|
|
|
|
import '../../../../core/services/rag_ai_service.dart';
|
|
|
|
|
import '../../../../core/utils/logger.dart';
|
|
|
|
|
|
|
|
|
|
class QuizListPage extends StatelessWidget {
|
|
|
|
|
class QuizListPage extends StatefulWidget {
|
|
|
|
|
const QuizListPage({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<QuizListPage> createState() => _QuizListPageState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _QuizListPageState extends State<QuizListPage>
|
|
|
|
|
with SingleTickerProviderStateMixin {
|
|
|
|
|
late TabController _tabController;
|
|
|
|
|
|
|
|
|
|
List<Map<String, String>> _materials = [];
|
|
|
|
|
List<Map<String, dynamic>> _history = [];
|
|
|
|
|
bool _loadingMaterials = true;
|
|
|
|
|
bool _loadingHistory = true;
|
|
|
|
|
|
|
|
|
|
// generating state
|
|
|
|
|
String? _generatingForId;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
|
|
|
_loadMaterials();
|
|
|
|
|
_loadHistory();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_tabController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadMaterials() async {
|
|
|
|
|
final mats = await MaterialsRAGService.getAvailableMaterialsForStudent();
|
|
|
|
|
if (mounted) setState(() { _materials = mats; _loadingMaterials = false; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadHistory() async {
|
|
|
|
|
try {
|
|
|
|
|
final uid = FirebaseAuth.instance.currentUser?.uid;
|
|
|
|
|
if (uid == null) { if (mounted) setState(() => _loadingHistory = false); return; }
|
|
|
|
|
final snap = await FirebaseFirestore.instance
|
|
|
|
|
.collection('quizHistory')
|
|
|
|
|
.doc(uid)
|
|
|
|
|
.collection('quizzes')
|
|
|
|
|
.orderBy('createdAt', descending: true)
|
|
|
|
|
.limit(30)
|
|
|
|
|
.get();
|
|
|
|
|
final list = snap.docs.map((d) => {'id': d.id, ...d.data()}).toList();
|
|
|
|
|
if (mounted) setState(() { _history = list; _loadingHistory = false; });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
Logger.error('Error loading quiz history: $e');
|
|
|
|
|
if (mounted) setState(() => _loadingHistory = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _generateQuiz(Map<String, String> material) async {
|
|
|
|
|
setState(() => _generatingForId = material['id']);
|
|
|
|
|
try {
|
|
|
|
|
final matId = material['id']!;
|
|
|
|
|
final matName = material['name'] ?? 'Material';
|
|
|
|
|
|
|
|
|
|
// Buscar contexto do PDF
|
|
|
|
|
final context = await MaterialsRAGService.getRelevantChunks(
|
|
|
|
|
userQuery: 'conteúdo geral resumo tópicos principais',
|
|
|
|
|
selectedMaterialIds: [matId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (context.isEmpty) {
|
|
|
|
|
if (mounted) _showSnack('Não foi possível aceder ao conteúdo do PDF.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Gerar quiz via Ollama em formato JSON estruturado
|
|
|
|
|
final prompt =
|
|
|
|
|
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
|
|
|
|
|
'$context\n\n'
|
|
|
|
|
'Cria um quiz com 5 perguntas de escolha múltipla sobre o conteúdo acima.\n'
|
|
|
|
|
'Responde SOMENTE com JSON válido, sem texto adicional, sem markdown, sem blocos de código.\n'
|
|
|
|
|
'Formato exacto:\n'
|
|
|
|
|
'[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...]\n'
|
|
|
|
|
'ans é o índice (0-3) da opção correcta.';
|
|
|
|
|
|
|
|
|
|
final raw = await RAGAIService.generateQuiz(prompt);
|
|
|
|
|
final questions = _parseQuizJson(raw);
|
|
|
|
|
|
|
|
|
|
if (questions.isEmpty) {
|
|
|
|
|
if (mounted) _showSnack('Não foi possível gerar o quiz. Tenta novamente.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guardar no histórico (guardar JSON raw para poder rever)
|
|
|
|
|
final uid = FirebaseAuth.instance.currentUser?.uid;
|
|
|
|
|
if (uid != null) {
|
|
|
|
|
await FirebaseFirestore.instance
|
|
|
|
|
.collection('quizHistory')
|
|
|
|
|
.doc(uid)
|
|
|
|
|
.collection('quizzes')
|
|
|
|
|
.add({
|
|
|
|
|
'materialId': matId,
|
|
|
|
|
'materialName': matName,
|
|
|
|
|
'quizJson': raw,
|
|
|
|
|
'createdAt': FieldValue.serverTimestamp(),
|
|
|
|
|
});
|
|
|
|
|
await _loadHistory();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mounted) _showInteractiveQuiz(matName, questions);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
Logger.error('Error generating quiz: $e');
|
|
|
|
|
if (mounted) _showSnack('Erro ao gerar quiz. Tenta novamente.');
|
|
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _generatingForId = null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse do JSON gerado pelo modelo — tolerante a erros
|
|
|
|
|
List<_QuizQuestion> _parseQuizJson(String raw) {
|
|
|
|
|
try {
|
|
|
|
|
// Extrair o primeiro array JSON encontrado na string
|
|
|
|
|
final start = raw.indexOf('[');
|
|
|
|
|
final end = raw.lastIndexOf(']');
|
|
|
|
|
if (start == -1 || end == -1 || end <= start) return [];
|
|
|
|
|
final jsonStr = raw.substring(start, end + 1);
|
|
|
|
|
final decoded = _jsonDecode(jsonStr);
|
|
|
|
|
if (decoded is! List) return [];
|
|
|
|
|
return decoded.map<_QuizQuestion?>((e) {
|
|
|
|
|
if (e is! Map) return null;
|
|
|
|
|
final q = e['q'] as String?;
|
|
|
|
|
final opts = (e['opts'] as List?)?.cast<String>();
|
|
|
|
|
final ans = e['ans'] as int?;
|
|
|
|
|
final exp = e['exp'] as String? ?? '';
|
|
|
|
|
if (q == null || opts == null || ans == null) return null;
|
|
|
|
|
if (opts.length < 2 || ans < 0 || ans >= opts.length) return null;
|
|
|
|
|
return _QuizQuestion(question: q, options: opts, correctIndex: ans, explanation: exp);
|
|
|
|
|
}).whereType<_QuizQuestion>().toList();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
Logger.error('Quiz JSON parse error: $e');
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dynamic _jsonDecode(String s) => jsonDecode(s);
|
|
|
|
|
|
|
|
|
|
void _showInteractiveQuiz(String title, List<_QuizQuestion> questions) {
|
|
|
|
|
showModalBottomSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
isScrollControlled: true,
|
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
|
isDismissible: false,
|
|
|
|
|
builder: (_) => _InteractiveQuizSheet(title: title, questions: questions),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showQuizFromHistory(String title, String rawJson) {
|
|
|
|
|
final questions = _parseQuizJson(rawJson);
|
|
|
|
|
if (questions.isEmpty) {
|
|
|
|
|
_showSnack('Não foi possível carregar este quiz.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_showInteractiveQuiz(title, questions);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showSnack(String msg) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
return PopScope(
|
|
|
|
|
canPop: false,
|
|
|
|
|
onPopInvoked: (didPop) {
|
|
|
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
|
|
|
if (didPop) return;
|
|
|
|
|
// Navigate back to dashboard instead of exiting app
|
|
|
|
|
context.go('/student-dashboard');
|
|
|
|
|
},
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.background,
|
|
|
|
|
backgroundColor: cs.surface,
|
|
|
|
|
appBar: AppBar(
|
|
|
|
|
title: const Text('Quizzes'),
|
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
|
|
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
|
|
|
|
title: const Text('Quiz'),
|
|
|
|
|
backgroundColor: cs.surface,
|
|
|
|
|
foregroundColor: cs.onSurface,
|
|
|
|
|
elevation: 0,
|
|
|
|
|
leading: IconButton(
|
|
|
|
|
icon: const Icon(Icons.arrow_back),
|
|
|
|
|
onPressed: () => context.go('/student-dashboard'),
|
|
|
|
|
),
|
|
|
|
|
body: Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
gradient: LinearGradient(
|
|
|
|
|
begin: Alignment.topLeft,
|
|
|
|
|
end: Alignment.bottomRight,
|
|
|
|
|
colors: [
|
|
|
|
|
Theme.of(context).colorScheme.background,
|
|
|
|
|
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
|
|
|
|
Theme.of(context).colorScheme.secondary.withOpacity(0.05),
|
|
|
|
|
Theme.of(context).colorScheme.background,
|
|
|
|
|
bottom: TabBar(
|
|
|
|
|
controller: _tabController,
|
|
|
|
|
labelColor: cs.primary,
|
|
|
|
|
unselectedLabelColor: cs.onSurfaceVariant,
|
|
|
|
|
indicatorColor: cs.primary,
|
|
|
|
|
tabs: const [
|
|
|
|
|
Tab(text: 'Gerar Quiz'),
|
|
|
|
|
Tab(text: 'Histórico'),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Text(
|
|
|
|
|
'Quiz List - Coming Soon',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 24,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
body: TabBarView(
|
|
|
|
|
controller: _tabController,
|
|
|
|
|
children: [
|
|
|
|
|
_buildMaterialsTab(cs),
|
|
|
|
|
_buildHistoryTab(cs),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildMaterialsTab(ColorScheme cs) {
|
|
|
|
|
if (_loadingMaterials) {
|
|
|
|
|
return const Center(child: CircularProgressIndicator());
|
|
|
|
|
}
|
|
|
|
|
if (_materials.isEmpty) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(32),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.folder_open, size: 64, color: cs.onSurfaceVariant.withOpacity(0.4)),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
'Nenhum material disponível.',
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
'Inscreve-te numa turma para aceder aos PDFs do professor.',
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: TextStyle(color: cs.onSurfaceVariant.withOpacity(0.7), fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return ListView.separated(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
itemCount: _materials.length,
|
|
|
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
|
|
|
itemBuilder: (context, i) {
|
|
|
|
|
final mat = _materials[i];
|
|
|
|
|
final isGenerating = _generatingForId == mat['id'];
|
|
|
|
|
final name = mat['name'] ?? 'Material';
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.surface,
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
border: Border.all(color: cs.outline.withOpacity(0.15)),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: cs.shadow.withOpacity(0.05),
|
|
|
|
|
blurRadius: 8,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: ListTile(
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
leading: Container(
|
|
|
|
|
width: 44,
|
|
|
|
|
height: 44,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.secondary.withOpacity(0.12),
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
),
|
|
|
|
|
child: Icon(Icons.picture_as_pdf, color: cs.secondary, size: 22),
|
|
|
|
|
),
|
|
|
|
|
title: Text(
|
|
|
|
|
name.replaceAll('.pdf', '').replaceAll('_', ' '),
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: cs.onSurface,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
'Toca para gerar um quiz',
|
|
|
|
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
|
|
|
|
),
|
|
|
|
|
trailing: isGenerating
|
|
|
|
|
? SizedBox(
|
|
|
|
|
width: 24,
|
|
|
|
|
height: 24,
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
strokeWidth: 2.5,
|
|
|
|
|
color: cs.primary,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: Icon(Icons.play_circle_outline, color: cs.primary, size: 28),
|
|
|
|
|
onTap: isGenerating ? null : () => _generateQuiz(mat),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildHistoryTab(ColorScheme cs) {
|
|
|
|
|
if (_loadingHistory) {
|
|
|
|
|
return const Center(child: CircularProgressIndicator());
|
|
|
|
|
}
|
|
|
|
|
if (_history.isEmpty) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(32),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.history, size: 64, color: cs.onSurfaceVariant.withOpacity(0.4)),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
'Ainda não geraste nenhum quiz.',
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return ListView.separated(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
itemCount: _history.length,
|
|
|
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
|
|
|
itemBuilder: (context, i) {
|
|
|
|
|
final item = _history[i];
|
|
|
|
|
final matName = (item['materialName'] as String? ?? 'Material')
|
|
|
|
|
.replaceAll('.pdf', '')
|
|
|
|
|
.replaceAll('_', ' ');
|
|
|
|
|
final ts = item['createdAt'];
|
|
|
|
|
String dateStr = '';
|
|
|
|
|
if (ts is Timestamp) {
|
|
|
|
|
final dt = ts.toDate();
|
|
|
|
|
dateStr = '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
|
|
|
|
}
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.surface,
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
border: Border.all(color: cs.outline.withOpacity(0.15)),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: cs.shadow.withOpacity(0.05),
|
|
|
|
|
blurRadius: 8,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: ListTile(
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
leading: Container(
|
|
|
|
|
width: 44,
|
|
|
|
|
height: 44,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.primary.withOpacity(0.1),
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
),
|
|
|
|
|
child: Icon(Icons.quiz, color: cs.primary, size: 22),
|
|
|
|
|
),
|
|
|
|
|
title: Text(
|
|
|
|
|
matName,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: cs.onSurface,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
subtitle: dateStr.isNotEmpty
|
|
|
|
|
? Text(dateStr, style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant))
|
|
|
|
|
: null,
|
|
|
|
|
trailing: Icon(Icons.chevron_right, color: cs.onSurfaceVariant),
|
|
|
|
|
onTap: () => _showQuizFromHistory(matName, item['quizJson'] as String? ?? item['quizText'] as String? ?? ''),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Modelo de dados ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class _QuizQuestion {
|
|
|
|
|
final String question;
|
|
|
|
|
final List<String> options;
|
|
|
|
|
final int correctIndex;
|
|
|
|
|
final String explanation;
|
|
|
|
|
const _QuizQuestion({
|
|
|
|
|
required this.question,
|
|
|
|
|
required this.options,
|
|
|
|
|
required this.correctIndex,
|
|
|
|
|
required this.explanation,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Sheet interativa ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class _InteractiveQuizSheet extends StatefulWidget {
|
|
|
|
|
final String title;
|
|
|
|
|
final List<_QuizQuestion> questions;
|
|
|
|
|
const _InteractiveQuizSheet({required this.title, required this.questions});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_InteractiveQuizSheet> createState() => _InteractiveQuizSheetState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
|
|
|
|
|
// índice da pergunta actual (-1 = resultados finais)
|
|
|
|
|
int _current = 0;
|
|
|
|
|
// respostas escolhidas: -1 = sem resposta
|
|
|
|
|
late List<int> _chosen;
|
|
|
|
|
bool _submitted = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_chosen = List.filled(widget.questions.length, -1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _selectOption(int idx) {
|
|
|
|
|
if (_submitted) return;
|
|
|
|
|
setState(() => _chosen[_current] = idx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _next() {
|
|
|
|
|
if (_current < widget.questions.length - 1) {
|
|
|
|
|
setState(() { _current++; });
|
|
|
|
|
} else {
|
|
|
|
|
setState(() => _submitted = true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _prev() {
|
|
|
|
|
if (_current > 0) setState(() { _current--; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int get _score => List.generate(widget.questions.length, (i) {
|
|
|
|
|
return _chosen[i] == widget.questions[i].correctIndex ? 1 : 0;
|
|
|
|
|
}).fold(0, (a, b) => a + b);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
return DraggableScrollableSheet(
|
|
|
|
|
initialChildSize: 0.93,
|
|
|
|
|
minChildSize: 0.6,
|
|
|
|
|
maxChildSize: 0.97,
|
|
|
|
|
builder: (_, scrollController) => Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.surface,
|
|
|
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
_buildHandle(cs),
|
|
|
|
|
_buildHeader(cs),
|
|
|
|
|
const Divider(height: 1),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _submitted
|
|
|
|
|
? _buildResults(cs, scrollController)
|
|
|
|
|
: _buildQuestion(cs, scrollController),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildHandle(ColorScheme cs) => Container(
|
|
|
|
|
margin: const EdgeInsets.only(top: 12, bottom: 4),
|
|
|
|
|
width: 40,
|
|
|
|
|
height: 4,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.outline.withOpacity(0.3),
|
|
|
|
|
borderRadius: BorderRadius.circular(2),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Widget _buildHeader(ColorScheme cs) => Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
widget.title,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: cs.onSurface),
|
|
|
|
|
),
|
|
|
|
|
if (!_submitted)
|
|
|
|
|
Text(
|
|
|
|
|
'Pergunta ${_current + 1} de ${widget.questions.length}',
|
|
|
|
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: Icon(Icons.close, color: cs.onSurfaceVariant),
|
|
|
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Widget _buildQuestion(ColorScheme cs, ScrollController sc) {
|
|
|
|
|
final q = widget.questions[_current];
|
|
|
|
|
final chosen = _chosen[_current];
|
|
|
|
|
return ListView(
|
|
|
|
|
controller: sc,
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
|
|
|
|
children: [
|
|
|
|
|
// Barra de progresso
|
|
|
|
|
ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
|
|
child: LinearProgressIndicator(
|
|
|
|
|
value: (_current + 1) / widget.questions.length,
|
|
|
|
|
minHeight: 6,
|
|
|
|
|
backgroundColor: cs.surfaceVariant,
|
|
|
|
|
valueColor: AlwaysStoppedAnimation(cs.primary),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
|
// Pergunta
|
|
|
|
|
Text(
|
|
|
|
|
q.question,
|
|
|
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface, height: 1.4),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
|
// Opções
|
|
|
|
|
...List.generate(q.options.length, (i) {
|
|
|
|
|
final isSelected = chosen == i;
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
|
|
|
child: InkWell(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
onTap: () => _selectOption(i),
|
|
|
|
|
child: AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 150),
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: isSelected ? cs.primary.withOpacity(0.12) : cs.surfaceVariant.withOpacity(0.4),
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: isSelected ? cs.primary : cs.outline.withOpacity(0.2),
|
|
|
|
|
width: isSelected ? 2 : 1,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
q.options[i],
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: isSelected ? cs.primary : cs.onSurface,
|
|
|
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (isSelected) Icon(Icons.check_circle, color: cs.primary, size: 20),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
// Botões navegação
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
if (_current > 0)
|
|
|
|
|
Expanded(
|
|
|
|
|
child: OutlinedButton(
|
|
|
|
|
onPressed: _prev,
|
|
|
|
|
style: OutlinedButton.styleFrom(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
|
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
|
|
|
),
|
|
|
|
|
child: const Text('Anterior'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (_current > 0) const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: ElevatedButton(
|
|
|
|
|
onPressed: chosen == -1 ? null : _next,
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: cs.primary,
|
|
|
|
|
foregroundColor: cs.onPrimary,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
|
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
|
|
|
),
|
|
|
|
|
child: Text(_current < widget.questions.length - 1 ? 'Próxima' : 'Submeter'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildResults(ColorScheme cs, ScrollController sc) {
|
|
|
|
|
final total = widget.questions.length;
|
|
|
|
|
final score = _score;
|
|
|
|
|
final pct = (score / total * 100).round();
|
|
|
|
|
final Color scoreColor = pct >= 80
|
|
|
|
|
? const Color(0xFF10B981)
|
|
|
|
|
: pct >= 50
|
|
|
|
|
? const Color(0xFFF59E0B)
|
|
|
|
|
: const Color(0xFFEF4444);
|
|
|
|
|
|
|
|
|
|
return ListView(
|
|
|
|
|
controller: sc,
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
|
|
|
|
|
children: [
|
|
|
|
|
// Resultado global
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.all(20),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: scoreColor.withOpacity(0.08),
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
border: Border.all(color: scoreColor.withOpacity(0.3)),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
'$score / $total',
|
|
|
|
|
style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold, color: scoreColor),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
Text(
|
|
|
|
|
'$pct% de respostas correctas',
|
|
|
|
|
style: TextStyle(fontSize: 14, color: scoreColor),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
Text(
|
|
|
|
|
'Revisão',
|
|
|
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: cs.onSurface),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
// Revisão pergunta a pergunta
|
|
|
|
|
...List.generate(total, (i) {
|
|
|
|
|
final q = widget.questions[i];
|
|
|
|
|
final chosen = _chosen[i];
|
|
|
|
|
final isCorrect = chosen == q.correctIndex;
|
|
|
|
|
final revColor = isCorrect ? const Color(0xFF10B981) : const Color(0xFFEF4444);
|
|
|
|
|
return Container(
|
|
|
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: revColor.withOpacity(0.06),
|
|
|
|
|
borderRadius: BorderRadius.circular(14),
|
|
|
|
|
border: Border.all(color: revColor.withOpacity(0.25)),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(isCorrect ? Icons.check_circle : Icons.cancel, color: revColor, size: 18),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
'Pergunta ${i + 1}',
|
|
|
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: revColor),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(q.question, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface)),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
if (chosen >= 0 && chosen < q.options.length && !isCorrect)
|
|
|
|
|
Text(
|
|
|
|
|
'A tua resposta: ${q.options[chosen]}',
|
|
|
|
|
style: const TextStyle(fontSize: 13, color: Color(0xFFEF4444)),
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
'Resposta correcta: ${q.options[q.correctIndex]}',
|
|
|
|
|
style: TextStyle(fontSize: 13, color: isCorrect ? const Color(0xFF10B981) : cs.onSurface, fontWeight: FontWeight.w500),
|
|
|
|
|
),
|
|
|
|
|
if (q.explanation.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.all(10),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: cs.surfaceVariant.withOpacity(0.5),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.info_outline, size: 14, color: cs.onSurfaceVariant),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
q.explanation,
|
|
|
|
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant, height: 1.4),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
// Botão fechar
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: cs.primary,
|
|
|
|
|
foregroundColor: cs.onPrimary,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
|
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
|
|
|
),
|
|
|
|
|
child: const Text('Fechar'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|