Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67e562423 | ||
|
|
82be8dbdf3 | ||
|
|
cd3b75995d | ||
|
|
f83dfd5717 | ||
|
|
afe86721ed | ||
|
|
25b005db3a | ||
|
|
1ae9413c71 | ||
|
|
f3f44c8c69 | ||
|
|
187d74db8d |
@@ -17,18 +17,21 @@ class DefaultDeck {
|
|||||||
prompt: 'What is the capital city of Australia?',
|
prompt: 'What is the capital city of Australia?',
|
||||||
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
|
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
|
||||||
correctAnswerIndices: [2],
|
correctAnswerIndices: [2],
|
||||||
|
explanation: 'Canberra was chosen as the capital in 1908 as a compromise between Sydney and Melbourne.',
|
||||||
),
|
),
|
||||||
Question(
|
Question(
|
||||||
id: 'gk_2',
|
id: 'gk_2',
|
||||||
prompt: 'Which planet is known as the Red Planet?',
|
prompt: 'Which planet is known as the Red Planet?',
|
||||||
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
|
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
|
||||||
correctAnswerIndices: [1],
|
correctAnswerIndices: [1],
|
||||||
|
explanation: 'Mars appears red due to iron oxide (rust) on its surface.',
|
||||||
),
|
),
|
||||||
Question(
|
Question(
|
||||||
id: 'gk_3',
|
id: 'gk_3',
|
||||||
prompt: 'What is the largest ocean on Earth?',
|
prompt: 'What is the largest ocean on Earth?',
|
||||||
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
|
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
|
||||||
correctAnswerIndices: [3],
|
correctAnswerIndices: [3],
|
||||||
|
explanation: 'The Pacific Ocean covers about 63 million square miles and is larger than all of Earth\'s land area combined.',
|
||||||
),
|
),
|
||||||
Question(
|
Question(
|
||||||
id: 'gk_4',
|
id: 'gk_4',
|
||||||
@@ -203,6 +206,7 @@ class DefaultDeck {
|
|||||||
prompt: 'What is the capital city of Australia?',
|
prompt: 'What is the capital city of Australia?',
|
||||||
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
|
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
|
||||||
correctAnswerIndices: [2],
|
correctAnswerIndices: [2],
|
||||||
|
explanation: 'Canberra was chosen as the capital in 1908 as a compromise between Sydney and Melbourne.',
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
isKnown: true,
|
isKnown: true,
|
||||||
priorityPoints: 0,
|
priorityPoints: 0,
|
||||||
@@ -216,6 +220,7 @@ class DefaultDeck {
|
|||||||
prompt: 'Which planet is known as the Red Planet?',
|
prompt: 'Which planet is known as the Red Planet?',
|
||||||
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
|
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
|
||||||
correctAnswerIndices: [1],
|
correctAnswerIndices: [1],
|
||||||
|
explanation: 'Mars appears red due to iron oxide (rust) on its surface.',
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
isKnown: true,
|
isKnown: true,
|
||||||
priorityPoints: 0,
|
priorityPoints: 0,
|
||||||
@@ -227,6 +232,7 @@ class DefaultDeck {
|
|||||||
Question(
|
Question(
|
||||||
id: 'gk_3',
|
id: 'gk_3',
|
||||||
prompt: 'What is the largest ocean on Earth?',
|
prompt: 'What is the largest ocean on Earth?',
|
||||||
|
explanation: 'The Pacific Ocean covers about 63 million square miles and is larger than all of Earth\'s land area combined.',
|
||||||
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
|
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
|
||||||
correctAnswerIndices: [3],
|
correctAnswerIndices: [3],
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:practice_engine/practice_engine.dart';
|
|||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
import '../widgets/status_chip.dart';
|
import '../widgets/status_chip.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
|
|
||||||
class AttemptResultScreen extends StatefulWidget {
|
class AttemptResultScreen extends StatefulWidget {
|
||||||
const AttemptResultScreen({super.key});
|
const AttemptResultScreen({super.key});
|
||||||
@@ -14,6 +15,7 @@ class AttemptResultScreen extends StatefulWidget {
|
|||||||
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||||
Deck? _deck;
|
Deck? _deck;
|
||||||
AttemptResult? _result;
|
AttemptResult? _result;
|
||||||
|
Attempt? _completedAttempt;
|
||||||
final DeckStorage _deckStorage = DeckStorage();
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,6 +31,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
||||||
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
|
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
|
||||||
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
|
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
|
||||||
|
_completedAttempt = args?['attempt'] as Attempt?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,16 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
|
|
||||||
void _repeatSameAttempt() {
|
void _repeatSameAttempt() {
|
||||||
if (_deck == null) return;
|
if (_deck == null) return;
|
||||||
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
|
// Pass the completed attempt so the attempt screen uses the same questions in the same order
|
||||||
|
if (_completedAttempt != null && _completedAttempt!.questions.isNotEmpty) {
|
||||||
|
Navigator.pushReplacementNamed(
|
||||||
|
context,
|
||||||
|
Routes.attempt,
|
||||||
|
arguments: {'deck': _deck, 'repeatAttempt': _completedAttempt},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _newAttempt() {
|
void _newAttempt() {
|
||||||
@@ -74,11 +86,155 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
if (_deck == null) return;
|
if (_deck == null) return;
|
||||||
// Save the updated deck to storage
|
// Save the updated deck to storage
|
||||||
_deckStorage.saveDeckSync(_deck!);
|
_deckStorage.saveDeckSync(_deck!);
|
||||||
// Navigate back to deck list
|
// Navigate back to this deck's overview (decks screen), not the home deck list
|
||||||
Navigator.pushNamedAndRemoveUntil(
|
Navigator.popUntil(context, (route) => route.settings.name == Routes.deckOverview);
|
||||||
|
Navigator.pushReplacementNamed(
|
||||||
context,
|
context,
|
||||||
Routes.deckList,
|
Routes.deckOverview,
|
||||||
(route) => false,
|
arguments: _deck,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Question? _currentQuestionInDeck(String questionId) {
|
||||||
|
if (_deck == null) return null;
|
||||||
|
try {
|
||||||
|
return _deck!.questions.firstWhere((q) => q.id == questionId);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFlag(String questionId) {
|
||||||
|
if (_deck == null) return;
|
||||||
|
setState(() {
|
||||||
|
_deck = DeckService.toggleQuestionFlag(deck: _deck!, questionId: questionId);
|
||||||
|
});
|
||||||
|
_deckStorage.saveDeckSync(_deck!);
|
||||||
|
final q = _currentQuestionInDeck(questionId);
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: q?.isFlagged == true ? 'Question flagged for review' : 'Question unflagged',
|
||||||
|
backgroundColor: q?.isFlagged == true ? Colors.orange : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markNeedsPractice(String questionId) {
|
||||||
|
if (_deck == null) return;
|
||||||
|
setState(() {
|
||||||
|
_deck = DeckService.markQuestionAsNeedsPractice(deck: _deck!, questionId: questionId);
|
||||||
|
});
|
||||||
|
_deckStorage.saveDeckSync(_deck!);
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Marked as needs practice',
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQuestionDetail(AnswerResult answerResult) {
|
||||||
|
final q = answerResult.question;
|
||||||
|
final correctSet = q.correctIndices.toSet();
|
||||||
|
final userSet = answerResult.userAnswerIndices.toSet();
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Question'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
q.prompt,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...q.answers.asMap().entries.map((e) {
|
||||||
|
final i = e.key;
|
||||||
|
final answer = e.value;
|
||||||
|
final isCorrect = correctSet.contains(i);
|
||||||
|
final isUser = userSet.contains(i);
|
||||||
|
String? label;
|
||||||
|
Color? color;
|
||||||
|
if (isCorrect && isUser) {
|
||||||
|
label = 'Correct (your answer)';
|
||||||
|
color = Colors.green;
|
||||||
|
} else if (isCorrect) {
|
||||||
|
label = 'Correct';
|
||||||
|
color = Colors.green;
|
||||||
|
} else if (isUser) {
|
||||||
|
label = 'Your answer';
|
||||||
|
color = Colors.red;
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: isCorrect
|
||||||
|
? Icon(Icons.check_circle, color: Colors.green, size: 20)
|
||||||
|
: isUser
|
||||||
|
? Icon(Icons.cancel, color: Colors.red, size: 20)
|
||||||
|
: const Icon(Icons.radio_button_unchecked, size: 20, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
if (label != null)
|
||||||
|
TextSpan(
|
||||||
|
text: '$label: ',
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: answer,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontWeight: label != null ? FontWeight.bold : null,
|
||||||
|
fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (q.explanation != null && q.explanation!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Explanation',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
q.explanation!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +373,8 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
..._result!.incorrectQuestions.map((answerResult) {
|
..._result!.incorrectQuestions.map((answerResult) {
|
||||||
|
final currentQ = _currentQuestionInDeck(answerResult.question.id);
|
||||||
|
final isFlagged = currentQ?.isFlagged ?? false;
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -227,29 +385,64 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
Text(
|
Text(
|
||||||
answerResult.question.prompt,
|
answerResult.question.prompt,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
StatusChip(statusChange: answerResult.statusChange),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
Text(
|
||||||
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
answerResult.userAnswerIndices.isEmpty
|
||||||
|
? 'No answer'
|
||||||
|
: 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
StatusChip(statusChange: answerResult.statusChange),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _showQuestionDetail(answerResult),
|
||||||
|
icon: const Icon(Icons.visibility, size: 22),
|
||||||
|
tooltip: 'View question details',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
||||||
|
icon: const Icon(Icons.replay, size: 22),
|
||||||
|
tooltip: 'Mark as needs practice',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _toggleFlag(answerResult.question.id),
|
||||||
|
icon: Icon(
|
||||||
|
isFlagged ? Icons.flag : Icons.outlined_flag,
|
||||||
|
color: isFlagged ? Colors.red : null,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -265,16 +458,65 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
..._result!.allResults.map((answerResult) {
|
..._result!.allResults.map((answerResult) {
|
||||||
|
final currentQ = _currentQuestionInDeck(answerResult.question.id);
|
||||||
|
final isFlagged = currentQ?.isFlagged ?? false;
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
title: Text(answerResult.question.prompt),
|
padding: const EdgeInsets.all(16),
|
||||||
subtitle: Text(
|
child: Column(
|
||||||
answerResult.isCorrect
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? 'Correct'
|
children: [
|
||||||
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
Text(
|
||||||
|
answerResult.question.prompt,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
answerResult.isCorrect
|
||||||
|
? 'Correct'
|
||||||
|
: answerResult.userAnswerIndices.isEmpty
|
||||||
|
? 'Incorrect - No answer'
|
||||||
|
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
StatusChip(statusChange: answerResult.statusChange),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _showQuestionDetail(answerResult),
|
||||||
|
icon: const Icon(Icons.visibility, size: 22),
|
||||||
|
tooltip: 'View question details',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
||||||
|
icon: const Icon(Icons.replay, size: 22),
|
||||||
|
tooltip: 'Mark as needs practice',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _toggleFlag(answerResult.question.id),
|
||||||
|
icon: Icon(
|
||||||
|
isFlagged ? Icons.flag : Icons.outlined_flag,
|
||||||
|
color: isFlagged ? Colors.red : null,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
trailing: StatusChip(statusChange: answerResult.statusChange),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
bool? includeKnown;
|
bool? includeKnown;
|
||||||
bool? resumeAttempt;
|
bool? resumeAttempt;
|
||||||
|
|
||||||
|
final repeatAttempt = args is Map<String, dynamic> ? args['repeatAttempt'] as Attempt? : null;
|
||||||
|
|
||||||
if (args is Map<String, dynamic>) {
|
if (args is Map<String, dynamic>) {
|
||||||
_deck = args['deck'] as Deck? ?? _createSampleDeck();
|
_deck = args['deck'] as Deck? ?? _createSampleDeck();
|
||||||
includeKnown = args['includeKnown'] as bool?;
|
includeKnown = args['includeKnown'] as bool?;
|
||||||
@@ -113,8 +115,35 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
resumeAttempt = false;
|
resumeAttempt = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should resume an incomplete attempt
|
// Check if we should repeat the exact same attempt (same questions, same order)
|
||||||
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
|
if (repeatAttempt != null && repeatAttempt.questions.isNotEmpty) {
|
||||||
|
final orderedIds = repeatAttempt.questions.map((q) => q.id).toList();
|
||||||
|
final questions = <Question>[];
|
||||||
|
for (final id in orderedIds) {
|
||||||
|
final q = _deck!.questions.where((q) => q.id == id).firstOrNull;
|
||||||
|
if (q != null) questions.add(q);
|
||||||
|
}
|
||||||
|
if (questions.isNotEmpty) {
|
||||||
|
_attempt = Attempt(
|
||||||
|
id: 'repeat-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
questions: questions,
|
||||||
|
startTime: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
// Same as new attempt: randomize answer order per question when config enables it
|
||||||
|
_answerOrderPerQuestion = {};
|
||||||
|
final shuffleAnswers = _deck!.config.shuffleAnswerOrder;
|
||||||
|
for (final q in _attempt!.questions) {
|
||||||
|
final order = List.generate(q.answers.length, (i) => i);
|
||||||
|
if (shuffleAnswers) {
|
||||||
|
order.shuffle(math.Random());
|
||||||
|
}
|
||||||
|
_answerOrderPerQuestion[q.id] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not repeating, check if we should resume an incomplete attempt
|
||||||
|
if (_attempt == null && resumeAttempt == true && _deck!.incompleteAttempt != null) {
|
||||||
final incomplete = _deck!.incompleteAttempt!;
|
final incomplete = _deck!.incompleteAttempt!;
|
||||||
_attempt = incomplete.toAttempt(_deck!.questions);
|
_attempt = incomplete.toAttempt(_deck!.questions);
|
||||||
_currentQuestionIndex = incomplete.currentQuestionIndex;
|
_currentQuestionIndex = incomplete.currentQuestionIndex;
|
||||||
@@ -133,7 +162,10 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
_pageController.jumpToPage(_currentQuestionIndex);
|
_pageController.jumpToPage(_currentQuestionIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Otherwise create a new attempt
|
||||||
|
if (_attempt == null) {
|
||||||
_attempt = _attemptService!.createAttempt(
|
_attempt = _attemptService!.createAttempt(
|
||||||
deck: _deck!,
|
deck: _deck!,
|
||||||
includeKnown: includeKnown,
|
includeKnown: includeKnown,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../utils/top_snackbar.dart';
|
import '../utils/top_snackbar.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
@@ -135,8 +138,10 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _save() {
|
/// Builds config from current form state, validates, persists if valid.
|
||||||
if (_deck == null || _config == null) return;
|
/// Returns true if saved, false if validation failed.
|
||||||
|
bool _applyAndPersist() {
|
||||||
|
if (_deck == null || _config == null) return false;
|
||||||
|
|
||||||
final consecutive = int.tryParse(_consecutiveController.text);
|
final consecutive = int.tryParse(_consecutiveController.text);
|
||||||
final attemptSize = int.tryParse(_attemptSizeController.text);
|
final attemptSize = int.tryParse(_attemptSizeController.text);
|
||||||
@@ -153,7 +158,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (consecutive < 1 ||
|
if (consecutive < 1 ||
|
||||||
@@ -166,7 +171,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time limit in seconds
|
// Calculate time limit in seconds
|
||||||
@@ -186,7 +191,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,21 +208,78 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
||||||
|
final deckStorage = DeckStorage();
|
||||||
|
deckStorage.saveDeckSync(updatedDeck);
|
||||||
|
|
||||||
// Show success message
|
setState(() {
|
||||||
showTopSnackBar(
|
_deck = updatedDeck;
|
||||||
context,
|
_config = updatedConfig;
|
||||||
message: 'Deck configuration saved successfully',
|
_configHashCode = updatedConfig.hashCode;
|
||||||
backgroundColor: Colors.green,
|
});
|
||||||
duration: const Duration(seconds: 1),
|
return true;
|
||||||
);
|
|
||||||
|
|
||||||
// Pop with the updated deck
|
|
||||||
Navigator.pop(context, updatedDeck);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancel() {
|
Map<String, dynamic> _configToJsonMap() {
|
||||||
Navigator.pop(context);
|
int? timeLimitSeconds;
|
||||||
|
if (_timeLimitEnabled) {
|
||||||
|
final hours = int.tryParse(_timeLimitHoursController.text) ?? 0;
|
||||||
|
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
|
||||||
|
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
|
||||||
|
timeLimitSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
if (timeLimitSeconds == 0) timeLimitSeconds = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'requiredConsecutiveCorrect': int.tryParse(_consecutiveController.text) ?? _config!.requiredConsecutiveCorrect,
|
||||||
|
'defaultAttemptSize': int.tryParse(_attemptSizeController.text) ?? _config!.defaultAttemptSize,
|
||||||
|
'priorityIncreaseOnIncorrect': int.tryParse(_priorityIncreaseController.text) ?? _config!.priorityIncreaseOnIncorrect,
|
||||||
|
'priorityDecreaseOnCorrect': int.tryParse(_priorityDecreaseController.text) ?? _config!.priorityDecreaseOnCorrect,
|
||||||
|
'immediateFeedbackEnabled': _immediateFeedback,
|
||||||
|
'includeKnownInAttempts': _includeKnownInAttempts,
|
||||||
|
'shuffleAnswerOrder': _shuffleAnswerOrder,
|
||||||
|
'excludeFlaggedQuestions': _excludeFlaggedQuestions,
|
||||||
|
if (timeLimitSeconds != null) 'timeLimitSeconds': timeLimitSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAsJson() {
|
||||||
|
if (_deck == null || _config == null) return;
|
||||||
|
final map = _configToJsonMap();
|
||||||
|
final jsonString = const JsonEncoder.withIndent(' ').convert(map);
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Attempt Settings as JSON'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
jsonString,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: jsonString));
|
||||||
|
Navigator.pop(context);
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'JSON copied to clipboard',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
|
label: const Text('Copy'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editDeck() {
|
void _editDeck() {
|
||||||
@@ -307,7 +369,18 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Deck Configuration'),
|
title: const Text('Attempt Settings'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context, _deck),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
onPressed: _showAsJson,
|
||||||
|
tooltip: 'Show as JSON',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -335,6 +408,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -347,6 +421,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -359,6 +434,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -371,6 +447,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -385,6 +462,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_immediateFeedback = value;
|
_immediateFeedback = value;
|
||||||
});
|
});
|
||||||
|
_applyAndPersist();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -400,6 +478,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_includeKnownInAttempts = value;
|
_includeKnownInAttempts = value;
|
||||||
});
|
});
|
||||||
|
_applyAndPersist();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -415,6 +494,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_shuffleAnswerOrder = value;
|
_shuffleAnswerOrder = value;
|
||||||
});
|
});
|
||||||
|
_applyAndPersist();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -430,6 +510,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_excludeFlaggedQuestions = value;
|
_excludeFlaggedQuestions = value;
|
||||||
});
|
});
|
||||||
|
_applyAndPersist();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -450,6 +531,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
_timeLimitSecondsController.clear();
|
_timeLimitSecondsController.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_applyAndPersist();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_timeLimitEnabled) ...[
|
if (_timeLimitEnabled) ...[
|
||||||
@@ -464,6 +546,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -475,6 +558,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -486,6 +570,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
onEditingComplete: _applyAndPersist,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -579,26 +664,6 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: _cancel,
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: _save,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../utils/top_snackbar.dart';
|
import '../utils/top_snackbar.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
@@ -16,6 +19,8 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
late TextEditingController _descriptionController;
|
late TextEditingController _descriptionController;
|
||||||
final List<QuestionEditor> _questionEditors = [];
|
final List<QuestionEditor> _questionEditors = [];
|
||||||
final DeckStorage _deckStorage = DeckStorage();
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
int? _focusNewQuestionIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,6 +53,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
for (final editor in _questionEditors) {
|
for (final editor in _questionEditors) {
|
||||||
@@ -59,6 +65,21 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
void _addQuestion() {
|
void _addQuestion() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_questionEditors.add(QuestionEditor.empty());
|
_questionEditors.add(QuestionEditor.empty());
|
||||||
|
_focusNewQuestionIndex = _questionEditors.length - 1;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final pos = _scrollController.position;
|
||||||
|
if (pos.maxScrollExtent > pos.pixels) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
pos.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Future.microtask(() {
|
||||||
|
if (mounted) setState(() => _focusNewQuestionIndex = null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +152,28 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
Navigator.pop(context, updatedDeck);
|
Navigator.pop(context, updatedDeck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _copyQuestionsJson() {
|
||||||
|
if (_questionEditors.isEmpty) {
|
||||||
|
showTopSnackBar(context, message: 'No questions to copy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final questions = _questionEditors.map((e) => e.toQuestion()).toList();
|
||||||
|
final list = questions.map((q) => {
|
||||||
|
'id': q.id,
|
||||||
|
'prompt': q.prompt,
|
||||||
|
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
|
||||||
|
'answers': q.answers,
|
||||||
|
'correctAnswerIndices': q.correctAnswerIndices,
|
||||||
|
}).toList();
|
||||||
|
final jsonString = const JsonEncoder.withIndent(' ').convert(list);
|
||||||
|
Clipboard.setData(ClipboardData(text: jsonString));
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: '${questions.length} question(s) copied as JSON',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_deck == null) {
|
if (_deck == null) {
|
||||||
@@ -143,6 +186,11 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Edit Deck'),
|
title: const Text('Edit Deck'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
onPressed: _copyQuestionsJson,
|
||||||
|
tooltip: 'Copy questions as JSON',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
onPressed: _addQuestion,
|
onPressed: _addQuestion,
|
||||||
@@ -156,6 +204,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -197,6 +246,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
onDelete: () => _removeQuestion(index),
|
onDelete: () => _removeQuestion(index),
|
||||||
onUnflag: null,
|
onUnflag: null,
|
||||||
onChanged: () => setState(() {}),
|
onChanged: () => setState(() {}),
|
||||||
|
requestFocusOnPrompt: _focusNewQuestionIndex == index,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -243,6 +293,7 @@ class QuestionEditorCard extends StatefulWidget {
|
|||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final VoidCallback? onUnflag;
|
final VoidCallback? onUnflag;
|
||||||
final VoidCallback onChanged;
|
final VoidCallback onChanged;
|
||||||
|
final bool requestFocusOnPrompt;
|
||||||
|
|
||||||
const QuestionEditorCard({
|
const QuestionEditorCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -251,6 +302,7 @@ class QuestionEditorCard extends StatefulWidget {
|
|||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onUnflag,
|
this.onUnflag,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.requestFocusOnPrompt = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -258,8 +310,31 @@ class QuestionEditorCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
||||||
|
final FocusNode _promptFocusNode = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_promptFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(QuestionEditorCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.requestFocusOnPrompt && !oldWidget.requestFocusOnPrompt) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _promptFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.requestFocusOnPrompt) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _promptFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -297,12 +372,23 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
|||||||
// Question Prompt
|
// Question Prompt
|
||||||
TextField(
|
TextField(
|
||||||
controller: widget.editor.promptController,
|
controller: widget.editor.promptController,
|
||||||
|
focusNode: _promptFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Question',
|
labelText: 'Question',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: widget.editor.explanationController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Explanation (optional)',
|
||||||
|
helperText: 'Shown when viewing question details after an attempt',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Answers
|
// Answers
|
||||||
@@ -399,12 +485,14 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
|||||||
|
|
||||||
class QuestionEditor {
|
class QuestionEditor {
|
||||||
final TextEditingController promptController;
|
final TextEditingController promptController;
|
||||||
|
final TextEditingController explanationController;
|
||||||
final List<TextEditingController> answerControllers;
|
final List<TextEditingController> answerControllers;
|
||||||
Set<int> correctAnswerIndices;
|
Set<int> correctAnswerIndices;
|
||||||
final String? originalId;
|
final String? originalId;
|
||||||
|
|
||||||
QuestionEditor({
|
QuestionEditor({
|
||||||
required this.promptController,
|
required this.promptController,
|
||||||
|
required this.explanationController,
|
||||||
required this.answerControllers,
|
required this.answerControllers,
|
||||||
Set<int>? correctAnswerIndices,
|
Set<int>? correctAnswerIndices,
|
||||||
this.originalId,
|
this.originalId,
|
||||||
@@ -413,6 +501,7 @@ class QuestionEditor {
|
|||||||
factory QuestionEditor.fromQuestion(Question question) {
|
factory QuestionEditor.fromQuestion(Question question) {
|
||||||
return QuestionEditor(
|
return QuestionEditor(
|
||||||
promptController: TextEditingController(text: question.prompt),
|
promptController: TextEditingController(text: question.prompt),
|
||||||
|
explanationController: TextEditingController(text: question.explanation ?? ''),
|
||||||
answerControllers: question.answers
|
answerControllers: question.answers
|
||||||
.map((answer) => TextEditingController(text: answer))
|
.map((answer) => TextEditingController(text: answer))
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -424,6 +513,7 @@ class QuestionEditor {
|
|||||||
factory QuestionEditor.empty() {
|
factory QuestionEditor.empty() {
|
||||||
return QuestionEditor(
|
return QuestionEditor(
|
||||||
promptController: TextEditingController(),
|
promptController: TextEditingController(),
|
||||||
|
explanationController: TextEditingController(),
|
||||||
answerControllers: [
|
answerControllers: [
|
||||||
TextEditingController(),
|
TextEditingController(),
|
||||||
TextEditingController(),
|
TextEditingController(),
|
||||||
@@ -446,9 +536,11 @@ class QuestionEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Question toQuestion() {
|
Question toQuestion() {
|
||||||
|
final explanationText = explanationController.text.trim();
|
||||||
return Question(
|
return Question(
|
||||||
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
prompt: promptController.text.trim(),
|
prompt: promptController.text.trim(),
|
||||||
|
explanation: explanationText.isEmpty ? null : explanationText,
|
||||||
answers: answerControllers.map((c) => c.text.trim()).toList(),
|
answers: answerControllers.map((c) => c.text.trim()).toList(),
|
||||||
correctAnswerIndices: correctAnswerIndices.toList()..sort(),
|
correctAnswerIndices: correctAnswerIndices.toList()..sort(),
|
||||||
);
|
);
|
||||||
@@ -456,6 +548,7 @@ class QuestionEditor {
|
|||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
promptController.dispose();
|
promptController.dispose();
|
||||||
|
explanationController.dispose();
|
||||||
for (final controller in answerControllers) {
|
for (final controller in answerControllers) {
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
return Question(
|
return Question(
|
||||||
id: questionMap['id'] as String? ?? '',
|
id: questionMap['id'] as String? ?? '',
|
||||||
prompt: questionMap['prompt'] as String? ?? '',
|
prompt: questionMap['prompt'] as String? ?? '',
|
||||||
|
explanation: questionMap['explanation'] as String?,
|
||||||
answers: (questionMap['answers'] as List<dynamic>?)
|
answers: (questionMap['answers'] as List<dynamic>?)
|
||||||
?.map((e) => e.toString())
|
?.map((e) => e.toString())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
@@ -118,7 +119,96 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
final deckStorage = DeckStorage();
|
final deckStorage = DeckStorage();
|
||||||
deckStorage.saveDeckSync(deck);
|
deckStorage.saveDeckSync(deck);
|
||||||
|
|
||||||
// Navigate back to deck list
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Deck created successfully',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _mergeWithExistingDeck() async {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = null;
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_jsonController.text.trim().isEmpty) {
|
||||||
|
throw FormatException('Please enter JSON data');
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedDeck = _parseDeckFromJson(_jsonController.text.trim());
|
||||||
|
if (parsedDeck == null || parsedDeck.questions.isEmpty) {
|
||||||
|
throw FormatException(
|
||||||
|
parsedDeck == null
|
||||||
|
? 'Failed to parse deck'
|
||||||
|
: 'JSON deck must contain at least one question to merge',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final deckToMerge = parsedDeck;
|
||||||
|
|
||||||
|
final deckStorage = DeckStorage();
|
||||||
|
final existingDecks = deckStorage.getAllDecksSync();
|
||||||
|
if (existingDecks.isEmpty) {
|
||||||
|
throw FormatException('No existing decks to merge into. Create or import a deck first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
final selectedDeck = await showDialog<Deck>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Merge with existing deck'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: existingDecks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final deck = existingDecks[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(deck.title),
|
||||||
|
subtitle: Text('${deck.questions.length} questions'),
|
||||||
|
onTap: () => Navigator.pop(context, deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedDeck == null || !mounted) return;
|
||||||
|
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final mergedQuestions = [
|
||||||
|
...selectedDeck.questions,
|
||||||
|
...deckToMerge.questions.asMap().entries.map((e) {
|
||||||
|
final q = e.value;
|
||||||
|
final i = e.key;
|
||||||
|
return q.copyWith(id: '${q.id}_merged_${timestamp}_$i');
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
final mergedDeck = selectedDeck.copyWith(questions: mergedQuestions);
|
||||||
|
deckStorage.saveDeckSync(mergedDeck);
|
||||||
|
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Added ${deckToMerge.questions.length} question(s) to "${selectedDeck.title}"',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -258,6 +348,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
'prompt': 'Sample Question $i?',
|
'prompt': 'Sample Question $i?',
|
||||||
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
||||||
'correctAnswerIndices': [i % 4],
|
'correctAnswerIndices': [i % 4],
|
||||||
|
if (i == 0) 'explanation': 'Optional explanation shown when viewing question details after an attempt.',
|
||||||
'isKnown': i < 5,
|
'isKnown': i < 5,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -391,12 +482,12 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Import Deck from JSON',
|
'Import from JSON',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Paste your deck JSON below. The format should include id, title, description, config, and questions.',
|
'Paste your deck JSON below. Create a new deck or merge questions into an existing one.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -412,32 +503,24 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
TextButton.icon(
|
||||||
'Deck JSON',
|
onPressed: _pickJsonFile,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
icon: const Icon(Icons.folder_open, size: 20),
|
||||||
|
label: const Text('Select file'),
|
||||||
),
|
),
|
||||||
Row(
|
TextButton.icon(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onPressed: _loadSampleDeck,
|
||||||
children: [
|
icon: const Icon(Icons.description, size: 20),
|
||||||
TextButton.icon(
|
label: const Text('Load Sample'),
|
||||||
onPressed: _pickJsonFile,
|
),
|
||||||
icon: const Icon(Icons.folder_open),
|
IconButton(
|
||||||
label: const Text('Select file'),
|
onPressed: _copyFieldToClipboard,
|
||||||
),
|
icon: const Icon(Icons.copy),
|
||||||
TextButton.icon(
|
tooltip: 'Copy field contents',
|
||||||
onPressed: _loadSampleDeck,
|
|
||||||
icon: const Icon(Icons.description),
|
|
||||||
label: const Text('Load Sample'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: _copyFieldToClipboard,
|
|
||||||
icon: const Icon(Icons.copy),
|
|
||||||
tooltip: 'Copy field contents',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -484,20 +567,43 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
|
|
||||||
if (_errorMessage != null) const SizedBox(height: 16),
|
if (_errorMessage != null) const SizedBox(height: 16),
|
||||||
|
|
||||||
// Import Button
|
// Import and Merge Buttons
|
||||||
FilledButton.icon(
|
Row(
|
||||||
onPressed: _isLoading ? null : _importDeck,
|
children: [
|
||||||
icon: _isLoading
|
Expanded(
|
||||||
? const SizedBox(
|
child: OutlinedButton.icon(
|
||||||
width: 20,
|
onPressed: _isLoading ? null : _mergeWithExistingDeck,
|
||||||
height: 20,
|
icon: _isLoading
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
? const SizedBox(
|
||||||
)
|
width: 20,
|
||||||
: const Icon(Icons.upload),
|
height: 20,
|
||||||
label: Text(_isLoading ? 'Importing...' : 'Import Deck'),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
style: FilledButton.styleFrom(
|
)
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
: const Icon(Icons.merge_type, size: 20),
|
||||||
),
|
label: Text(_isLoading ? '...' : 'Merge with deck'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _isLoading ? null : _importDeck,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add_circle_outline, size: 20),
|
||||||
|
label: Text(_isLoading ? 'Importing...' : 'Import and create deck'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -531,6 +637,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
const Text('• answers: array of strings (required)'),
|
const Text('• answers: array of strings (required)'),
|
||||||
const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'),
|
const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'),
|
||||||
const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'),
|
const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'),
|
||||||
|
const Text('• explanation: string (optional, shown when viewing question details)'),
|
||||||
const Text('• isKnown: boolean (optional)'),
|
const Text('• isKnown: boolean (optional)'),
|
||||||
const Text('• consecutiveCorrect: number (optional)'),
|
const Text('• consecutiveCorrect: number (optional)'),
|
||||||
const Text('• priorityPoints: number (optional)'),
|
const Text('• priorityPoints: number (optional)'),
|
||||||
|
|||||||
@@ -102,6 +102,68 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _mergeDeck(Deck sourceDeck) async {
|
||||||
|
final otherDecks = _decks.where((d) => d.id != sourceDeck.id).toList();
|
||||||
|
if (otherDecks.isEmpty) {
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'No other deck to merge with',
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetDeck = await showDialog<Deck>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Merge with deck'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: otherDecks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final deck = otherDecks[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(deck.title),
|
||||||
|
subtitle: Text('${deck.questions.length} questions'),
|
||||||
|
onTap: () => Navigator.pop(context, deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetDeck == null || !mounted) return;
|
||||||
|
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final mergedQuestions = [
|
||||||
|
...targetDeck.questions,
|
||||||
|
...sourceDeck.questions.asMap().entries.map((e) {
|
||||||
|
final q = e.value;
|
||||||
|
final i = e.key;
|
||||||
|
return q.copyWith(id: '${q.id}_merged_${timestamp}_$i');
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
final mergedDeck = targetDeck.copyWith(questions: mergedQuestions);
|
||||||
|
_deckStorage.saveDeckSync(mergedDeck);
|
||||||
|
_loadDecks();
|
||||||
|
if (mounted) {
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Merged ${sourceDeck.questions.length} question(s) into "${targetDeck.title}"',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _cloneDeck(Deck deck) {
|
void _cloneDeck(Deck deck) {
|
||||||
// Create a copy of the deck with a new ID and reset progress
|
// Create a copy of the deck with a new ID and reset progress
|
||||||
final clonedDeck = deck.copyWith(
|
final clonedDeck = deck.copyWith(
|
||||||
@@ -452,6 +514,21 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.merge_type, color: Colors.teal),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Merge'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
() => _mergeDeck(deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -73,6 +73,68 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
|||||||
showTopSnackBar(context, message: 'Question unflagged');
|
showTopSnackBar(context, message: 'Question unflagged');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _removeQuestion(int index) {
|
||||||
|
if (_deck == null || index < 0 || index >= _editors.length) return;
|
||||||
|
final questionId = _editors[index].originalId;
|
||||||
|
if (questionId == null) return;
|
||||||
|
final updatedQuestions =
|
||||||
|
_deck!.questions.where((q) => q.id != questionId).toList();
|
||||||
|
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
||||||
|
_deckStorage.saveDeckSync(updatedDeck);
|
||||||
|
_editors[index].dispose();
|
||||||
|
setState(() {
|
||||||
|
_deck = updatedDeck;
|
||||||
|
_editors.removeAt(index);
|
||||||
|
});
|
||||||
|
showTopSnackBar(context, message: 'Question removed from deck');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeAllQuestions() async {
|
||||||
|
if (_deck == null || _editors.isEmpty) return;
|
||||||
|
final count = _editors.length;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Remove all flagged questions?'),
|
||||||
|
content: Text(
|
||||||
|
'Remove all $count flagged question${count == 1 ? '' : 's'} from the deck? This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('Remove all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
final idsToRemove =
|
||||||
|
_editors.map((e) => e.originalId).whereType<String>().toSet();
|
||||||
|
final updatedQuestions =
|
||||||
|
_deck!.questions.where((q) => !idsToRemove.contains(q.id)).toList();
|
||||||
|
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
||||||
|
_deckStorage.saveDeckSync(updatedDeck);
|
||||||
|
for (final e in _editors) {
|
||||||
|
e.dispose();
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_deck = updatedDeck;
|
||||||
|
_editors.clear();
|
||||||
|
});
|
||||||
|
showTopSnackBar(
|
||||||
|
context,
|
||||||
|
message: '$count question${count == 1 ? '' : 's'} removed from deck',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _save() {
|
void _save() {
|
||||||
if (_deck == null) return;
|
if (_deck == null) return;
|
||||||
for (int i = 0; i < _editors.length; i++) {
|
for (int i = 0; i < _editors.length; i++) {
|
||||||
@@ -94,8 +156,10 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
|||||||
if (id == null) continue;
|
if (id == null) continue;
|
||||||
final existing = questionMap[id];
|
final existing = questionMap[id];
|
||||||
if (existing == null) continue;
|
if (existing == null) continue;
|
||||||
|
final explanationText = editor.explanationController.text.trim();
|
||||||
final updated = existing.copyWith(
|
final updated = existing.copyWith(
|
||||||
prompt: editor.promptController.text.trim(),
|
prompt: editor.promptController.text.trim(),
|
||||||
|
explanation: explanationText.isEmpty ? null : explanationText,
|
||||||
answers: editor.answerControllers.map((c) => c.text.trim()).toList(),
|
answers: editor.answerControllers.map((c) => c.text.trim()).toList(),
|
||||||
correctAnswerIndices: editor.correctAnswerIndices.toList()..sort(),
|
correctAnswerIndices: editor.correctAnswerIndices.toList()..sort(),
|
||||||
);
|
);
|
||||||
@@ -141,6 +205,7 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
|||||||
final list = questions.map((q) => {
|
final list = questions.map((q) => {
|
||||||
'id': q.id,
|
'id': q.id,
|
||||||
'prompt': q.prompt,
|
'prompt': q.prompt,
|
||||||
|
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
|
||||||
'answers': q.answers,
|
'answers': q.answers,
|
||||||
'correctAnswerIndices': q.correctAnswerIndices,
|
'correctAnswerIndices': q.correctAnswerIndices,
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -162,6 +227,11 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
|||||||
title: Text('Flagged questions (${_editors.length})'),
|
title: Text('Flagged questions (${_editors.length})'),
|
||||||
actions: [
|
actions: [
|
||||||
if (hasEditors) ...[
|
if (hasEditors) ...[
|
||||||
|
IconButton(
|
||||||
|
onPressed: _removeAllQuestions,
|
||||||
|
icon: const Icon(Icons.delete_forever),
|
||||||
|
tooltip: 'Remove all from deck',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _save,
|
onPressed: _save,
|
||||||
icon: const Icon(Icons.save),
|
icon: const Icon(Icons.save),
|
||||||
@@ -216,7 +286,7 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
|||||||
key: ValueKey('flagged_${_editors[index].originalId}_$index'),
|
key: ValueKey('flagged_${_editors[index].originalId}_$index'),
|
||||||
editor: _editors[index],
|
editor: _editors[index],
|
||||||
questionNumber: index + 1,
|
questionNumber: index + 1,
|
||||||
onDelete: null,
|
onDelete: () => _removeQuestion(index),
|
||||||
onUnflag: () => _unflag(index),
|
onUnflag: () => _unflag(index),
|
||||||
onChanged: () => setState(() {}),
|
onChanged: () => setState(() {}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class DeckStorage {
|
|||||||
'questions': deck.questions.map((q) => {
|
'questions': deck.questions.map((q) => {
|
||||||
'id': q.id,
|
'id': q.id,
|
||||||
'prompt': q.prompt,
|
'prompt': q.prompt,
|
||||||
|
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
|
||||||
'answers': q.answers,
|
'answers': q.answers,
|
||||||
'correctAnswerIndices': q.correctAnswerIndices,
|
'correctAnswerIndices': q.correctAnswerIndices,
|
||||||
'consecutiveCorrect': q.consecutiveCorrect,
|
'consecutiveCorrect': q.consecutiveCorrect,
|
||||||
@@ -133,6 +134,7 @@ class DeckStorage {
|
|||||||
return Question(
|
return Question(
|
||||||
id: questionMap['id'] as String? ?? '',
|
id: questionMap['id'] as String? ?? '',
|
||||||
prompt: questionMap['prompt'] as String? ?? '',
|
prompt: questionMap['prompt'] as String? ?? '',
|
||||||
|
explanation: questionMap['explanation'] as String?,
|
||||||
answers: (questionMap['answers'] as List<dynamic>?)
|
answers: (questionMap['answers'] as List<dynamic>?)
|
||||||
?.map((e) => e.toString())
|
?.map((e) => e.toString())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
|
|||||||
@@ -80,29 +80,32 @@ class AttemptService {
|
|||||||
.firstOrNull ?? questionInAttempt;
|
.firstOrNull ?? questionInAttempt;
|
||||||
|
|
||||||
final userAnswer = answers[currentQuestion.id];
|
final userAnswer = answers[currentQuestion.id];
|
||||||
if (userAnswer == null) {
|
|
||||||
// Question not answered, treat as incorrect
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both single answer (int) and multiple answers (List<int>)
|
// Not answering or invalid format: treat same as incorrect
|
||||||
final List<int> userAnswerIndices;
|
final List<int> userAnswerIndices;
|
||||||
if (userAnswer is int) {
|
final bool isCorrect;
|
||||||
|
if (userAnswer == null) {
|
||||||
|
userAnswerIndices = [];
|
||||||
|
isCorrect = false;
|
||||||
|
} else if (userAnswer is int) {
|
||||||
userAnswerIndices = [userAnswer];
|
userAnswerIndices = [userAnswer];
|
||||||
|
final correctIndices = currentQuestion.correctIndices;
|
||||||
|
final userSet = userAnswerIndices.toSet();
|
||||||
|
final correctSet = correctIndices.toSet();
|
||||||
|
isCorrect = userSet.length == correctSet.length &&
|
||||||
|
userSet.every((idx) => correctSet.contains(idx));
|
||||||
} else if (userAnswer is List<int>) {
|
} else if (userAnswer is List<int>) {
|
||||||
userAnswerIndices = userAnswer;
|
userAnswerIndices = userAnswer;
|
||||||
|
final correctIndices = currentQuestion.correctIndices;
|
||||||
|
final userSet = userAnswerIndices.toSet();
|
||||||
|
final correctSet = correctIndices.toSet();
|
||||||
|
isCorrect = userSet.length == correctSet.length &&
|
||||||
|
userSet.every((idx) => correctSet.contains(idx));
|
||||||
} else {
|
} else {
|
||||||
// Invalid format, treat as incorrect
|
userAnswerIndices = [];
|
||||||
continue;
|
isCorrect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if answer is correct
|
|
||||||
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
|
||||||
final correctIndices = currentQuestion.correctIndices;
|
|
||||||
final userSet = userAnswerIndices.toSet();
|
|
||||||
final correctSet = correctIndices.toSet();
|
|
||||||
final isCorrect = userSet.length == correctSet.length &&
|
|
||||||
userSet.every((idx) => correctSet.contains(idx));
|
|
||||||
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
|
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
|
||||||
|
|
||||||
// Determine status change (from current deck state)
|
// Determine status change (from current deck state)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ class Question {
|
|||||||
/// The question prompt.
|
/// The question prompt.
|
||||||
final String prompt;
|
final String prompt;
|
||||||
|
|
||||||
|
/// Optional explanation shown when viewing question details (e.g. after an attempt).
|
||||||
|
final String? explanation;
|
||||||
|
|
||||||
/// List of possible answers.
|
/// List of possible answers.
|
||||||
final List<String> answers;
|
final List<String> answers;
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ class Question {
|
|||||||
Question({
|
Question({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.prompt,
|
required this.prompt,
|
||||||
|
this.explanation,
|
||||||
required this.answers,
|
required this.answers,
|
||||||
List<int>? correctAnswerIndices,
|
List<int>? correctAnswerIndices,
|
||||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||||
@@ -60,6 +64,7 @@ class Question {
|
|||||||
Question copyWith({
|
Question copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? prompt,
|
String? prompt,
|
||||||
|
String? explanation,
|
||||||
List<String>? answers,
|
List<String>? answers,
|
||||||
List<int>? correctAnswerIndices,
|
List<int>? correctAnswerIndices,
|
||||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||||
@@ -74,6 +79,7 @@ class Question {
|
|||||||
return Question(
|
return Question(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
prompt: prompt ?? this.prompt,
|
prompt: prompt ?? this.prompt,
|
||||||
|
explanation: explanation ?? this.explanation,
|
||||||
answers: answers ?? this.answers,
|
answers: answers ?? this.answers,
|
||||||
correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices,
|
correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices,
|
||||||
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
|
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
|
||||||
@@ -119,6 +125,7 @@ class Question {
|
|||||||
runtimeType == other.runtimeType &&
|
runtimeType == other.runtimeType &&
|
||||||
id == other.id &&
|
id == other.id &&
|
||||||
prompt == other.prompt &&
|
prompt == other.prompt &&
|
||||||
|
explanation == other.explanation &&
|
||||||
answers.toString() == other.answers.toString() &&
|
answers.toString() == other.answers.toString() &&
|
||||||
correctAnswerIndices.toString() == other.correctAnswerIndices.toString() &&
|
correctAnswerIndices.toString() == other.correctAnswerIndices.toString() &&
|
||||||
consecutiveCorrect == other.consecutiveCorrect &&
|
consecutiveCorrect == other.consecutiveCorrect &&
|
||||||
@@ -133,6 +140,7 @@ class Question {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
id.hashCode ^
|
id.hashCode ^
|
||||||
prompt.hashCode ^
|
prompt.hashCode ^
|
||||||
|
(explanation?.hashCode ?? 0) ^
|
||||||
answers.hashCode ^
|
answers.hashCode ^
|
||||||
correctAnswerIndices.hashCode ^
|
correctAnswerIndices.hashCode ^
|
||||||
consecutiveCorrect.hashCode ^
|
consecutiveCorrect.hashCode ^
|
||||||
|
|||||||
Reference in New Issue
Block a user