You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/screens/attempt_screen.dart

1001 lines
35 KiB

import 'package:flutter/material.dart';
import '../utils/top_snackbar.dart';
import 'dart:math' as math;
import 'dart:async';
import 'dart:ui';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/question_card.dart';
import '../widgets/answer_option.dart';
import '../services/deck_storage.dart';
/// Custom 3D cube page transformer
class CubePageTransitionsBuilder extends PageTransitionsBuilder {
const CubePageTransitionsBuilder();
@override
Widget buildTransitions<T extends Object?>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _CubeTransition(
animation: animation,
child: child,
);
}
}
class _CubeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _CubeTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final value = animation.value;
final angle = (1.0 - value) * math.pi / 2;
final opacity = value < 0.5 ? 0.0 : (value - 0.5) * 2;
return Transform(
alignment: Alignment.centerLeft,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: child,
),
);
},
child: child,
);
}
}
class AttemptScreen extends StatefulWidget {
const AttemptScreen({super.key});
@override
State<AttemptScreen> createState() => _AttemptScreenState();
}
class _AttemptScreenState extends State<AttemptScreen> {
Deck? _deck;
Attempt? _attempt;
AttemptService? _attemptService;
int _currentQuestionIndex = 0;
int? _selectedAnswerIndex; // For single answer questions (backward compatibility)
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
Map<String, dynamic> _answers = {}; // Can store int or List<int>
Map<String, bool> _manualOverrides = {};
/// Display order of answer indices per question (shuffled for new attempts).
Map<String, List<int>> _answerOrderPerQuestion = {};
final DeckStorage _deckStorage = DeckStorage();
late PageController _pageController;
Timer? _timer;
int _remainingSeconds = 0;
@override
void initState() {
super.initState();
_attemptService = AttemptService();
_pageController = PageController();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
bool? includeKnown;
bool? resumeAttempt;
if (args is Map<String, dynamic>) {
_deck = args['deck'] as Deck? ?? _createSampleDeck();
includeKnown = args['includeKnown'] as bool?;
resumeAttempt = args['resumeAttempt'] as bool? ?? false;
} else if (args is Deck) {
_deck = args;
resumeAttempt = false;
} else {
_deck = _createSampleDeck();
resumeAttempt = false;
}
// Check if we should resume an incomplete attempt
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
final incomplete = _deck!.incompleteAttempt!;
_attempt = incomplete.toAttempt(_deck!.questions);
_currentQuestionIndex = incomplete.currentQuestionIndex;
_answers = Map<String, dynamic>.from(incomplete.answers);
_manualOverrides = Map<String, bool>.from(incomplete.manualOverrides);
// Resumed attempt: use identity order so saved answers (original indices) match
_answerOrderPerQuestion = {
for (final q in _attempt!.questions)
q.id: List.generate(q.answers.length, (i) => i),
};
// Restore selected answer for current question
_loadQuestionState();
// Initialize PageController to the current question index
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_currentQuestionIndex);
}
});
} else {
_attempt = _attemptService!.createAttempt(
deck: _deck!,
includeKnown: includeKnown,
);
// New attempt: randomize answer order per question when deck 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;
}
}
// Initialize timer if time limit is set
if (_deck!.config.timeLimitSeconds != null) {
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
// Use remaining time from incomplete attempt
if (_deck!.incompleteAttempt!.remainingSeconds != null) {
_remainingSeconds = _deck!.incompleteAttempt!.remainingSeconds!;
} else {
// Fallback: calculate remaining time when resuming (for backward compatibility)
final pausedAt = _deck!.incompleteAttempt!.pausedAt;
final elapsedSeconds = (DateTime.now().millisecondsSinceEpoch - pausedAt) ~/ 1000;
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
}
} else {
_remainingSeconds = _deck!.config.timeLimitSeconds!;
}
_startTimer();
}
}
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_remainingSeconds > 0) {
_remainingSeconds--;
} else {
// Time expired
timer.cancel();
_handleTimeExpired();
}
});
});
}
void _handleTimeExpired() {
// Auto-submit the attempt when time expires
if (_attempt != null && _deck != null) {
_completeAttempt();
}
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
super.dispose();
}
Deck _createSampleDeck() {
const config = DeckConfig();
final questions = List.generate(10, (i) {
return Question(
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['A', 'B', 'C', 'D'],
correctAnswerIndices: [i % 4],
);
});
return Deck(
id: 'sample',
title: 'Sample',
description: 'Sample',
questions: questions,
config: config,
);
}
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
bool get _hasMultipleCorrect => _currentQuestion.hasMultipleCorrectAnswers;
bool get _hasAnswer {
if (_hasMultipleCorrect) {
return _selectedAnswerIndices.isNotEmpty;
} else {
return _selectedAnswerIndex != null;
}
}
/// 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
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _goToNextQuestion() {
if (_currentQuestionIndex < _attempt!.questions.length - 1) {
// Save current answer if any
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
String _formatTime(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m ${secs}s';
} else if (minutes > 0) {
return '${minutes}m ${secs}s';
} else {
return '${secs}s';
}
}
void _onPageChanged(int index) {
setState(() {
_currentQuestionIndex = index;
_loadQuestionState();
});
}
void _loadQuestionState() {
// Load saved answer for current question
final currentQuestionId = _currentQuestion.id;
final savedAnswer = _answers[currentQuestionId];
if (savedAnswer != null) {
if (savedAnswer is int) {
_selectedAnswerIndex = savedAnswer;
_selectedAnswerIndices.clear();
} else if (savedAnswer is List<int>) {
_selectedAnswerIndices = savedAnswer.toSet();
_selectedAnswerIndex = null;
}
} else {
_selectedAnswerIndex = null;
_selectedAnswerIndices.clear();
}
}
void _submitAnswer() {
if (!_hasAnswer) return;
// Store answer(s) based on question type
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
if (_isLastQuestion) {
_completeAttempt();
} else {
_goToNextQuestion();
}
}
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;
// Save current answer if selected
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
// Create incomplete attempt
final incompleteAttempt = IncompleteAttempt(
attemptId: _attempt!.id,
questionIds: _attempt!.questions.map((q) => q.id).toList(),
startTime: _attempt!.startTime,
currentQuestionIndex: _currentQuestionIndex,
answers: _answers,
manualOverrides: _manualOverrides,
pausedAt: DateTime.now().millisecondsSinceEpoch,
remainingSeconds: _deck!.config.timeLimitSeconds != null ? _remainingSeconds : null,
);
// Update deck with incomplete attempt
final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt);
_deckStorage.saveDeckSync(updatedDeck);
showTopSnackBar(
context,
message: 'Attempt saved. You can continue later.',
backgroundColor: Colors.blue,
duration: const Duration(seconds: 2),
);
Navigator.pop(context);
}
void _completeAttempt() {
if (_deck == null || _attempt == null || _attemptService == null) return;
final endTime = DateTime.now().millisecondsSinceEpoch;
final result = _attemptService!.processAttempt(
deck: _deck!,
attempt: _attempt!,
answers: _answers,
manualOverrides: _manualOverrides,
endTime: endTime,
);
// Add attempt to history
final historyEntry = AttemptHistoryEntry.fromAttemptResult(
result: result.result,
totalQuestionsInDeck: result.updatedDeck.numberOfQuestions,
knownCount: result.updatedDeck.knownCount,
timestamp: endTime,
);
final updatedDeckWithHistory = result.updatedDeck.copyWith(
attemptHistory: [...result.updatedDeck.attemptHistory, historyEntry],
clearIncompleteAttempt: true, // Clear incomplete attempt when completed
);
// Save the updated deck to storage
_deckStorage.saveDeckSync(updatedDeckWithHistory);
Navigator.pushReplacementNamed(
context,
Routes.attemptResult,
arguments: {
'deck': updatedDeckWithHistory,
'result': result.result,
'attempt': _attempt,
},
);
}
@override
Widget build(BuildContext context) {
if (_deck == null || _attempt == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return PopScope(
canPop: false,
onPopInvoked: (bool didPop) async {
if (didPop) return;
// Only prompt to save if there's actual progress (at least one question answered)
if (!_hasAnyProgress) {
// No progress made, just allow exit without prompt
if (mounted) {
Navigator.of(context).pop();
}
return;
}
// Ask if user wants to save for later
final shouldSave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit Attempt?'),
content: const Text(
'Your progress will be lost. Would you like to save this attempt to continue later?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Discard'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Save for Later'),
),
],
),
);
if (!mounted) return;
if (shouldSave == true) {
_saveForLater();
// _saveForLater will handle navigation
} else if (shouldSave == false) {
// User chose to discard, allow pop
Navigator.of(context).pop();
}
// If cancelled (shouldSave == null), don't pop
},
child: Scaffold(
appBar: AppBar(
title: Text('Attempt - ${_deck!.title}'),
actions: [
// Flag current question for review
Builder(
builder: (context) {
if (_attempt!.questions.isEmpty) return const SizedBox.shrink();
final currentQuestionId = _attempt!.questions[_currentQuestionIndex].id;
final isFlagged = _deck!.questions
.where((q) => q.id == currentQuestionId)
.firstOrNull
?.isFlagged ?? false;
return IconButton(
onPressed: () {
setState(() {
_deck = DeckService.toggleQuestionFlag(
deck: _deck!,
questionId: currentQuestionId,
);
_deckStorage.saveDeckSync(_deck!);
});
showTopSnackBar(
context,
message: isFlagged ? 'Question unflagged' : 'Question flagged for review',
backgroundColor: isFlagged ? null : Colors.orange,
);
},
icon: Icon(
isFlagged ? Icons.flag : Icons.outlined_flag,
color: isFlagged ? Colors.red : null,
),
tooltip: isFlagged ? 'Unflag question' : 'Flag for review',
);
},
),
if (_hasUnansweredQuestions)
IconButton(
onPressed: _jumpToFirstUnanswered,
icon: const Icon(Icons.skip_next),
tooltip: 'Jump to first unanswered question',
),
if (_hasAnyProgress)
IconButton(
onPressed: _saveForLater,
icon: const Icon(Icons.pause),
tooltip: 'Continue Later',
),
],
),
body: Column(
children: [
// Time Limit Countdown Bar (if time limit is set)
if (_deck!.config.timeLimitSeconds != null)
Container(
height: 6,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: _remainingSeconds / _deck!.config.timeLimitSeconds!,
child: Container(
decoration: BoxDecoration(
color: _remainingSeconds <= 60
? Colors.red
: _remainingSeconds <= 300
? Colors.orange
: Colors.green,
),
),
),
),
// Progress Indicator
LinearProgressIndicator(
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
minHeight: 4,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
style: Theme.of(context).textTheme.bodyMedium,
),
Row(
children: [
if (_deck!.config.timeLimitSeconds != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _remainingSeconds <= 60
? Colors.red.withValues(alpha: 0.2)
: _remainingSeconds <= 300
? Colors.orange.withValues(alpha: 0.2)
: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatTime(_remainingSeconds),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _remainingSeconds <= 60
? Colors.red
: _remainingSeconds <= 300
? Colors.orange
: Colors.green,
),
),
),
if (_deck!.config.timeLimitSeconds != null && _currentQuestion.isKnown)
const SizedBox(width: 8),
if (_currentQuestion.isKnown)
const Chip(
label: Text('Known'),
avatar: Icon(Icons.check_circle, size: 18),
),
],
),
],
),
),
// Question Card with PageView for 3D transitions
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _attempt!.questions.length,
physics: const BouncingScrollPhysics(), // Always allow swiping, validation happens in onPageChanged
itemBuilder: (context, index) {
final question = _attempt!.questions[index];
final isMultipleCorrect = question.hasMultipleCorrectAnswers;
final savedAnswer = _answers[question.id];
// Determine selected answers for this question
int? selectedIndex;
Set<int> selectedIndices = {};
if (savedAnswer != null) {
if (savedAnswer is int) {
selectedIndex = savedAnswer;
} else if (savedAnswer is List<int>) {
selectedIndices = savedAnswer.toSet();
}
}
return AnimatedBuilder(
animation: _pageController,
builder: (context, child) {
double value = 1.0;
double angle = 0.0;
if (_pageController.position.haveDimensions) {
value = _pageController.page! - index;
value = (1 - (value.abs() * 0.5)).clamp(0.0, 1.0);
// Calculate rotation angle for 3D cube effect
final pageOffset = _pageController.page! - index;
if (pageOffset.abs() < 1.0) {
angle = pageOffset * math.pi / 2;
} else if (pageOffset < 0) {
angle = -math.pi / 2;
} else {
angle = math.pi / 2;
}
}
return Transform(
alignment: Alignment.centerLeft,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: Opacity(
opacity: value.clamp(0.0, 1.0),
child: child,
),
);
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
QuestionCard(question: question),
const SizedBox(height: 24),
// Answer Options (displayed in shuffled order; we store original indices)
...() {
final order = _answerOrderPerQuestion[question.id] ??
List.generate(question.answers.length, (i) => i);
return List.generate(
question.answers.length,
(displayIndex) {
final originalIndex = order[displayIndex];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AnswerOption(
text: question.answers[originalIndex],
isSelected: isMultipleCorrect
? selectedIndices.contains(originalIndex)
: selectedIndex == originalIndex,
onTap: () {
setState(() {
if (isMultipleCorrect) {
if (selectedIndices.contains(originalIndex)) {
selectedIndices.remove(originalIndex);
} else {
selectedIndices.add(originalIndex);
}
_answers[question.id] = selectedIndices.toList()..sort();
} else {
if (selectedIndex == originalIndex) {
selectedIndex = null;
_answers.remove(question.id);
} else {
selectedIndex = originalIndex;
_answers[question.id] = selectedIndex;
}
}
if (index == _currentQuestionIndex) {
if (isMultipleCorrect) {
_selectedAnswerIndices = selectedIndices;
_selectedAnswerIndex = null;
} else {
_selectedAnswerIndex = selectedIndex;
_selectedAnswerIndices.clear();
}
}
});
},
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
(isMultipleCorrect
? selectedIndices.contains(originalIndex)
: selectedIndex == originalIndex)
? question.isCorrectAnswer(originalIndex)
: null,
isMultipleChoice: isMultipleCorrect,
),
);
},
);
}(),
const SizedBox(height: 24),
// Manual Override Buttons
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Manual Override',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_manualOverrides[question.id] = false;
_deck = DeckService.markQuestionAsKnown(
deck: _deck!,
questionId: question.id,
);
});
showTopSnackBar(
context,
message: 'Question marked as Known',
backgroundColor: Colors.green,
);
},
icon: const Icon(Icons.check_circle),
label: const Text('Mark as Known'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_manualOverrides[question.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: question.id,
);
});
showTopSnackBar(
context,
message: 'Question marked as Needs Practice',
);
},
icon: const Icon(Icons.school),
label: const Text('Needs Practice'),
),
),
],
),
],
),
),
),
],
),
),
); // Close AnimatedBuilder
},
pageSnapping: true,
),
),
// Navigation Buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: [
// Previous Button
Expanded(
child: OutlinedButton.icon(
onPressed: _currentQuestionIndex > 0 ? _goToPreviousQuestion : null,
style: OutlinedButton.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_back, size: 20),
label: const Text(
'Previous',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(width: 12),
// Next/Complete Button
Expanded(
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),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: Icon(
hasUnanswered ? Icons.warning : Icons.check_circle,
size: 24,
color: hasUnanswered ? null : Colors.green,
),
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,
),
),
);
}
}

Powered by TurnKey Linux.