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( PageRoute route, BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { return _CubeTransition( animation: animation, child: child, ); } } class _CubeTransition extends StatelessWidget { final Animation 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 createState() => _AttemptScreenState(); } class _AttemptScreenState extends State { Deck? _deck; Attempt? _attempt; AttemptService? _attemptService; int _currentQuestionIndex = 0; int? _selectedAnswerIndex; // For single answer questions (backward compatibility) Set _selectedAnswerIndices = {}; // For multiple answer questions Map _answers = {}; // Can store int or List Map _manualOverrides = {}; /// Display order of answer indices per question (shuffled for new attempts). Map> _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) { _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.from(incomplete.answers); _manualOverrides = Map.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) { _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( 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( 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 selectedIndices = {}; if (savedAnswer != null) { if (savedAnswer is int) { selectedIndex = savedAnswer; } else if (savedAnswer is List) { 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, ), ), ); } }