|
|
|
|
@ -81,7 +81,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
late PageController _pageController;
|
|
|
|
|
Timer? _timer;
|
|
|
|
|
int _remainingSeconds = 0;
|
|
|
|
|
int _startTime = 0;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
@ -148,7 +147,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
_startTime = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
|
_remainingSeconds = _deck!.config.timeLimitSeconds!;
|
|
|
|
|
}
|
|
|
|
|
_startTimer();
|
|
|
|
|
@ -223,6 +221,39 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
/// Returns true if at least one question has been answered (progress has been made)
|
|
|
|
|
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
|
|
|
|
|
|
|
|
|
|
/// Returns true if there are unanswered questions
|
|
|
|
|
bool get _hasUnansweredQuestions {
|
|
|
|
|
if (_attempt == null) return false;
|
|
|
|
|
for (final question in _attempt!.questions) {
|
|
|
|
|
if (!_answers.containsKey(question.id)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the index of the first unanswered question, or null if all are answered
|
|
|
|
|
int? get _firstUnansweredQuestionIndex {
|
|
|
|
|
if (_attempt == null) return null;
|
|
|
|
|
for (int i = 0; i < _attempt!.questions.length; i++) {
|
|
|
|
|
if (!_answers.containsKey(_attempt!.questions[i].id)) {
|
|
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the index of the next unanswered question after current, or null
|
|
|
|
|
int? get _nextUnansweredQuestionIndex {
|
|
|
|
|
if (_attempt == null) return null;
|
|
|
|
|
for (int i = _currentQuestionIndex + 1; i < _attempt!.questions.length; i++) {
|
|
|
|
|
if (!_answers.containsKey(_attempt!.questions[i].id)) {
|
|
|
|
|
return i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _goToPreviousQuestion() {
|
|
|
|
|
if (_currentQuestionIndex > 0) {
|
|
|
|
|
// Save current answer if any
|
|
|
|
|
@ -316,6 +347,82 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _completeAttemptWithUnanswered() async {
|
|
|
|
|
// Ask for confirmation
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => AlertDialog(
|
|
|
|
|
title: const Text('Complete Attempt?'),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Text('There are still unanswered questions.'),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
'Are you sure you want to complete this attempt?',
|
|
|
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(context, false),
|
|
|
|
|
child: const Text('Cancel'),
|
|
|
|
|
),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => Navigator.pop(context, true),
|
|
|
|
|
child: const Text('Complete Anyway'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (confirmed == true && mounted) {
|
|
|
|
|
_completeAttempt();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _jumpToFirstUnanswered() {
|
|
|
|
|
// Save current answer if any
|
|
|
|
|
if (_hasAnswer) {
|
|
|
|
|
if (_hasMultipleCorrect) {
|
|
|
|
|
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
|
|
|
|
} else {
|
|
|
|
|
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final firstUnanswered = _firstUnansweredQuestionIndex;
|
|
|
|
|
if (firstUnanswered != null && _pageController.hasClients) {
|
|
|
|
|
_pageController.animateToPage(
|
|
|
|
|
firstUnanswered,
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeInOut,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _goToNextUnanswered() {
|
|
|
|
|
// Save current answer if any
|
|
|
|
|
if (_hasAnswer) {
|
|
|
|
|
if (_hasMultipleCorrect) {
|
|
|
|
|
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
|
|
|
|
} else {
|
|
|
|
|
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final nextUnanswered = _nextUnansweredQuestionIndex;
|
|
|
|
|
if (nextUnanswered != null && _pageController.hasClients) {
|
|
|
|
|
_pageController.animateToPage(
|
|
|
|
|
nextUnanswered,
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeInOut,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _saveForLater() {
|
|
|
|
|
if (_deck == null || _attempt == null) return;
|
|
|
|
|
|
|
|
|
|
@ -448,12 +555,19 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
appBar: AppBar(
|
|
|
|
|
title: Text('Attempt - ${_deck!.title}'),
|
|
|
|
|
actions: [
|
|
|
|
|
// Show "Jump to First Unanswered" if there are unanswered questions
|
|
|
|
|
if (_hasUnansweredQuestions)
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: _jumpToFirstUnanswered,
|
|
|
|
|
icon: const Icon(Icons.skip_next),
|
|
|
|
|
tooltip: 'Jump to first unanswered question',
|
|
|
|
|
),
|
|
|
|
|
// Only show "Continue Later" button if there's progress
|
|
|
|
|
if (_hasAnyProgress)
|
|
|
|
|
TextButton.icon(
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: _saveForLater,
|
|
|
|
|
icon: const Icon(Icons.pause),
|
|
|
|
|
label: const Text('Continue Later'),
|
|
|
|
|
tooltip: 'Continue Later',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
@ -613,10 +727,16 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
selectedIndices.add(answerIndex);
|
|
|
|
|
}
|
|
|
|
|
_answers[question.id] = selectedIndices.toList()..sort();
|
|
|
|
|
} else {
|
|
|
|
|
// Toggle: if already selected, deselect; otherwise select
|
|
|
|
|
if (selectedIndex == answerIndex) {
|
|
|
|
|
selectedIndex = null;
|
|
|
|
|
_answers.remove(question.id);
|
|
|
|
|
} else {
|
|
|
|
|
selectedIndex = answerIndex;
|
|
|
|
|
_answers[question.id] = selectedIndex;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Update current question state if viewing it
|
|
|
|
|
if (index == _currentQuestionIndex) {
|
|
|
|
|
if (isMultipleCorrect) {
|
|
|
|
|
@ -707,6 +827,7 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
); // Close AnimatedBuilder
|
|
|
|
|
},
|
|
|
|
|
pageSnapping: true,
|
|
|
|
|
),
|
|
|
|
|
@ -753,10 +874,29 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
// Next/Complete Button
|
|
|
|
|
Expanded(
|
|
|
|
|
child: FilledButton.icon(
|
|
|
|
|
onPressed: _isLastQuestion
|
|
|
|
|
? (_hasAnswer ? _submitAnswer : null)
|
|
|
|
|
: _goToNextQuestion,
|
|
|
|
|
child: _isLastQuestion
|
|
|
|
|
? _buildCompleteButton()
|
|
|
|
|
: _buildNextButton(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildCompleteButton() {
|
|
|
|
|
final hasUnanswered = _hasUnansweredQuestions;
|
|
|
|
|
// Allow completing if current question is answered OR if there are unanswered questions (to show warning)
|
|
|
|
|
final canComplete = _hasAnswer || hasUnanswered;
|
|
|
|
|
|
|
|
|
|
return FilledButton.icon(
|
|
|
|
|
onPressed: canComplete
|
|
|
|
|
? (hasUnanswered ? _completeAttemptWithUnanswered : _submitAnswer)
|
|
|
|
|
: null,
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
|
|
|
|
minimumSize: const Size(0, 56),
|
|
|
|
|
@ -765,25 +905,44 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
icon: Icon(
|
|
|
|
|
_isLastQuestion ? Icons.check_circle : Icons.arrow_forward,
|
|
|
|
|
hasUnanswered ? Icons.warning : Icons.check_circle,
|
|
|
|
|
size: 24,
|
|
|
|
|
color: _isLastQuestion ? Colors.green : null,
|
|
|
|
|
color: hasUnanswered ? null : Colors.green,
|
|
|
|
|
),
|
|
|
|
|
label: Text(
|
|
|
|
|
_isLastQuestion ? 'Complete' : 'Next',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
label: const Text(
|
|
|
|
|
'Complete',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
letterSpacing: 0.5,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildNextButton() {
|
|
|
|
|
final nextUnanswered = _nextUnansweredQuestionIndex;
|
|
|
|
|
final hasUnansweredAhead = nextUnanswered != null;
|
|
|
|
|
|
|
|
|
|
return FilledButton.icon(
|
|
|
|
|
onPressed: hasUnansweredAhead ? _goToNextUnanswered : _goToNextQuestion,
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
|
|
|
|
minimumSize: const Size(0, 56),
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
icon: const Icon(
|
|
|
|
|
Icons.arrow_forward,
|
|
|
|
|
size: 24,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
label: const Text(
|
|
|
|
|
'Next',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
letterSpacing: 0.5,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|