Compare commits

9 Commits
Author SHA1 Message Date
gitea b67e562423 sample json fix 2026-02-03 23:12:51 +01:00
gitea 82be8dbdf3 explanation 2026-02-03 19:34:33 +01:00
gitea cd3b75995d improve card layout 2026-02-03 19:14:05 +01:00
gitea f83dfd5717 merge decks 2026-02-01 23:12:31 +01:00
gitea afe86721ed import and merge 2 2026-02-01 22:43:41 +01:00
gitea 25b005db3a import and merge 2026-02-01 22:35:56 +01:00
gitea 1ae9413c71 improvements in editing questions 2026-02-01 22:21:17 +01:00
gitea f3f44c8c69 flag and end of attempt 2 2026-02-01 21:38:37 +01:00
gitea 187d74db8d flag and end of attempt 2026-02-01 21:37:19 +01:00
11 changed files with 833 additions and 128 deletions
+6
View File
@@ -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,
+259 -17
View File
@@ -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,8 +65,17 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
void _repeatSameAttempt() { void _repeatSameAttempt() {
if (_deck == null) return; if (_deck == null) return;
// 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); Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
} }
}
void _newAttempt() { void _newAttempt() {
if (_deck == null) return; if (_deck == null) return;
@@ -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,28 +385,63 @@ 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),
),
const SizedBox(height: 8),
Text( Text(
'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', 'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle( style: TextStyle(
color: Colors.green, color: Colors.green,
fontWeight: FontWeight.bold, 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',
),
],
),
],
), ),
], ],
), ),
@@ -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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
answerResult.question.prompt,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
const SizedBox(height: 4),
Text(
answerResult.isCorrect answerResult.isCorrect
? 'Correct' ? 'Correct'
: answerResult.userAnswerIndices.isEmpty
? 'Incorrect - No answer'
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', : '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),
), ),
); );
}), }),
+35 -3
View File
@@ -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,
+100 -35
View File
@@ -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(() {
_deck = updatedDeck;
_config = updatedConfig;
_configHashCode = updatedConfig.hashCode;
});
return true;
}
Map<String, dynamic> _configToJsonMap() {
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( showTopSnackBar(
context, context,
message: 'Deck configuration saved successfully', message: 'JSON copied to clipboard',
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
); );
},
// Pop with the updated deck icon: const Icon(Icons.copy, size: 20),
Navigator.pop(context, updatedDeck); label: const Text('Copy'),
} ),
],
void _cancel() { ),
Navigator.pop(context); );
} }
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'),
),
),
],
),
], ],
), ),
), ),
+93
View File
@@ -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();
} }
+127 -20
View File
@@ -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,24 +503,18 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8,
children: [ runSpacing: 4,
Text(
'Deck JSON',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton.icon( TextButton.icon(
onPressed: _pickJsonFile, onPressed: _pickJsonFile,
icon: const Icon(Icons.folder_open), icon: const Icon(Icons.folder_open, size: 20),
label: const Text('Select file'), label: const Text('Select file'),
), ),
TextButton.icon( TextButton.icon(
onPressed: _loadSampleDeck, onPressed: _loadSampleDeck,
icon: const Icon(Icons.description), icon: const Icon(Icons.description, size: 20),
label: const Text('Load Sample'), label: const Text('Load Sample'),
), ),
IconButton( IconButton(
@@ -439,8 +524,6 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
), ),
], ],
), ),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _jsonController, controller: _jsonController,
@@ -484,8 +567,28 @@ 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(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _mergeWithExistingDeck,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: 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, onPressed: _isLoading ? null : _importDeck,
icon: _isLoading icon: _isLoading
? const SizedBox( ? const SizedBox(
@@ -493,12 +596,15 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.upload), : const Icon(Icons.add_circle_outline, size: 20),
label: Text(_isLoading ? 'Importing...' : 'Import Deck'), label: Text(_isLoading ? 'Importing...' : 'Import and create deck'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), 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)'),
+77
View File
@@ -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: [
+71 -1
View File
@@ -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(() {}),
); );
+2
View File
@@ -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];
} else if (userAnswer is List<int>) {
userAnswerIndices = userAnswer;
} else {
// Invalid format, treat as incorrect
continue;
}
// Check if answer is correct
// For multiple correct answers: user must select all correct answers and no incorrect ones
final correctIndices = currentQuestion.correctIndices; final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet(); final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet(); final correctSet = correctIndices.toSet();
final isCorrect = userSet.length == correctSet.length && isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx)); userSet.every((idx) => correctSet.contains(idx));
} else if (userAnswer is List<int>) {
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 {
userAnswerIndices = [];
isCorrect = false;
}
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 ^