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.
971 lines
34 KiB
971 lines
34 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: [
|
|
// 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)
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|