Compare commits
3
Commits
1.0
..
1ae9413c71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae9413c71 | ||
|
|
f3f44c8c69 | ||
|
|
187d74db8d |
@@ -3,6 +3,7 @@ import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
import '../widgets/status_chip.dart';
|
||||
import '../services/deck_storage.dart';
|
||||
import '../utils/top_snackbar.dart';
|
||||
|
||||
class AttemptResultScreen extends StatefulWidget {
|
||||
const AttemptResultScreen({super.key});
|
||||
@@ -14,6 +15,7 @@ class AttemptResultScreen extends StatefulWidget {
|
||||
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
Deck? _deck;
|
||||
AttemptResult? _result;
|
||||
Attempt? _completedAttempt;
|
||||
final DeckStorage _deckStorage = DeckStorage();
|
||||
|
||||
@override
|
||||
@@ -29,6 +31,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
|
||||
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
|
||||
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
|
||||
_completedAttempt = args?['attempt'] as Attempt?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +65,16 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
|
||||
void _repeatSameAttempt() {
|
||||
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() {
|
||||
@@ -74,11 +86,48 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
if (_deck == null) return;
|
||||
// Save the updated deck to storage
|
||||
_deckStorage.saveDeckSync(_deck!);
|
||||
// Navigate back to deck list
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
// Navigate back to this deck's overview (decks screen), not the home deck list
|
||||
Navigator.popUntil(context, (route) => route.settings.name == Routes.deckOverview);
|
||||
Navigator.pushReplacementNamed(
|
||||
context,
|
||||
Routes.deckList,
|
||||
(route) => false,
|
||||
Routes.deckOverview,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +266,8 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._result!.incorrectQuestions.map((answerResult) {
|
||||
final currentQ = _currentQuestionInDeck(answerResult.question.id);
|
||||
final isFlagged = currentQ?.isFlagged ?? false;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
@@ -233,23 +284,44 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
children: [
|
||||
StatusChip(statusChange: answerResult.statusChange),
|
||||
const Spacer(),
|
||||
Text(
|
||||
answerResult.userAnswerIndices.isEmpty
|
||||
? 'No answer'
|
||||
: 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.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(
|
||||
color: Colors.red,
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
||||
icon: const Icon(Icons.replay, size: 20),
|
||||
label: const Text('Needs practice'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _toggleFlag(answerResult.question.id),
|
||||
icon: Icon(
|
||||
isFlagged ? Icons.flag : Icons.outlined_flag,
|
||||
color: isFlagged ? Colors.red : null,
|
||||
),
|
||||
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -265,6 +337,8 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._result!.allResults.map((answerResult) {
|
||||
final currentQ = _currentQuestionInDeck(answerResult.question.id);
|
||||
final isFlagged = currentQ?.isFlagged ?? false;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
@@ -272,9 +346,30 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||
subtitle: Text(
|
||||
answerResult.isCorrect
|
||||
? 'Correct'
|
||||
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||
: answerResult.userAnswerIndices.isEmpty
|
||||
? 'Incorrect - No answer'
|
||||
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusChip(statusChange: answerResult.statusChange),
|
||||
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? resumeAttempt;
|
||||
|
||||
final repeatAttempt = args is Map<String, dynamic> ? args['repeatAttempt'] as Attempt? : null;
|
||||
|
||||
if (args is Map<String, dynamic>) {
|
||||
_deck = args['deck'] as Deck? ?? _createSampleDeck();
|
||||
includeKnown = args['includeKnown'] as bool?;
|
||||
@@ -112,9 +114,36 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
_deck = _createSampleDeck();
|
||||
resumeAttempt = false;
|
||||
}
|
||||
|
||||
// Check if we should resume an incomplete attempt
|
||||
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
|
||||
|
||||
// Check if we should repeat the exact same attempt (same questions, same order)
|
||||
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!;
|
||||
_attempt = incomplete.toAttempt(_deck!.questions);
|
||||
_currentQuestionIndex = incomplete.currentQuestionIndex;
|
||||
@@ -133,7 +162,10 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
_pageController.jumpToPage(_currentQuestionIndex);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
// Otherwise create a new attempt
|
||||
if (_attempt == null) {
|
||||
_attempt = _attemptService!.createAttempt(
|
||||
deck: _deck!,
|
||||
includeKnown: includeKnown,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../utils/top_snackbar.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../services/deck_storage.dart';
|
||||
@@ -135,8 +138,10 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_deck == null || _config == null) return;
|
||||
/// Builds config from current form state, validates, persists if valid.
|
||||
/// Returns true if saved, false if validation failed.
|
||||
bool _applyAndPersist() {
|
||||
if (_deck == null || _config == null) return false;
|
||||
|
||||
final consecutive = int.tryParse(_consecutiveController.text);
|
||||
final attemptSize = int.tryParse(_attemptSizeController.text);
|
||||
@@ -153,7 +158,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consecutive < 1 ||
|
||||
@@ -166,7 +171,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate time limit in seconds
|
||||
@@ -176,7 +181,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
|
||||
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
|
||||
final totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||
|
||||
|
||||
if (totalSeconds > 0) {
|
||||
timeLimitSeconds = totalSeconds;
|
||||
} else {
|
||||
@@ -186,7 +191,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,21 +208,78 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
);
|
||||
|
||||
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
||||
final deckStorage = DeckStorage();
|
||||
deckStorage.saveDeckSync(updatedDeck);
|
||||
|
||||
// Show success message
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: 'Deck configuration saved successfully',
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
// Pop with the updated deck
|
||||
Navigator.pop(context, updatedDeck);
|
||||
setState(() {
|
||||
_deck = updatedDeck;
|
||||
_config = updatedConfig;
|
||||
_configHashCode = updatedConfig.hashCode;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
Navigator.pop(context);
|
||||
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(
|
||||
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() {
|
||||
@@ -307,7 +369,18 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
|
||||
return Scaffold(
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -335,6 +408,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -347,6 +421,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -359,6 +434,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -371,6 +447,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -385,6 +462,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
setState(() {
|
||||
_immediateFeedback = value;
|
||||
});
|
||||
_applyAndPersist();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -400,6 +478,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
setState(() {
|
||||
_includeKnownInAttempts = value;
|
||||
});
|
||||
_applyAndPersist();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -415,6 +494,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
setState(() {
|
||||
_shuffleAnswerOrder = value;
|
||||
});
|
||||
_applyAndPersist();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -430,6 +510,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
setState(() {
|
||||
_excludeFlaggedQuestions = value;
|
||||
});
|
||||
_applyAndPersist();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -450,6 +531,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
_timeLimitSecondsController.clear();
|
||||
}
|
||||
});
|
||||
_applyAndPersist();
|
||||
},
|
||||
),
|
||||
if (_timeLimitEnabled) ...[
|
||||
@@ -464,6 +546,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -475,6 +558,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onEditingComplete: _applyAndPersist,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -486,6 +570,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,8 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
late TextEditingController _descriptionController;
|
||||
final List<QuestionEditor> _questionEditors = [];
|
||||
final DeckStorage _deckStorage = DeckStorage();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int? _focusNewQuestionIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +50,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
for (final editor in _questionEditors) {
|
||||
@@ -59,6 +62,21 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
void _addQuestion() {
|
||||
setState(() {
|
||||
_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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +174,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -197,6 +216,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
onDelete: () => _removeQuestion(index),
|
||||
onUnflag: null,
|
||||
onChanged: () => setState(() {}),
|
||||
requestFocusOnPrompt: _focusNewQuestionIndex == index,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -243,6 +263,7 @@ class QuestionEditorCard extends StatefulWidget {
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onUnflag;
|
||||
final VoidCallback onChanged;
|
||||
final bool requestFocusOnPrompt;
|
||||
|
||||
const QuestionEditorCard({
|
||||
super.key,
|
||||
@@ -251,6 +272,7 @@ class QuestionEditorCard extends StatefulWidget {
|
||||
this.onDelete,
|
||||
this.onUnflag,
|
||||
required this.onChanged,
|
||||
this.requestFocusOnPrompt = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -258,8 +280,31 @@ class QuestionEditorCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.requestFocusOnPrompt) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _promptFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
@@ -297,6 +342,7 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
||||
// Question Prompt
|
||||
TextField(
|
||||
controller: widget.editor.promptController,
|
||||
focusNode: _promptFocusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Question',
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -80,29 +80,32 @@ class AttemptService {
|
||||
.firstOrNull ?? questionInAttempt;
|
||||
|
||||
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;
|
||||
if (userAnswer is int) {
|
||||
final bool isCorrect;
|
||||
if (userAnswer == null) {
|
||||
userAnswerIndices = [];
|
||||
isCorrect = false;
|
||||
} else if (userAnswer is 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 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 {
|
||||
// Invalid format, treat as incorrect
|
||||
continue;
|
||||
userAnswerIndices = [];
|
||||
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;
|
||||
|
||||
// Determine status change (from current deck state)
|
||||
|
||||
Reference in New Issue
Block a user