import 'package:flutter/material.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 = {}; final DeckStorage _deckStorage = DeckStorage(); late PageController _pageController; Timer? _timer; int _remainingSeconds = 0; int _startTime = 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); // 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, ); } // 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 { _startTime = DateTime.now().millisecondsSinceEpoch; _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; 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 _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.saveDeck(updatedDeck); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Attempt saved. You can continue later.'), backgroundColor: Colors.blue, duration: 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, 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.saveDeck(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: [ // Only show "Continue Later" button if there's progress if (_hasAnyProgress) TextButton.icon( onPressed: _saveForLater, icon: const Icon(Icons.pause), label: const Text('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 ...List.generate( question.answers.length, (answerIndex) => Padding( padding: const EdgeInsets.only(bottom: 8), child: AnswerOption( text: question.answers[answerIndex], isSelected: isMultipleCorrect ? selectedIndices.contains(answerIndex) : selectedIndex == answerIndex, onTap: () { // Update selection for this question setState(() { if (isMultipleCorrect) { if (selectedIndices.contains(answerIndex)) { selectedIndices.remove(answerIndex); } else { selectedIndices.add(answerIndex); } _answers[question.id] = selectedIndices.toList()..sort(); } else { selectedIndex = answerIndex; _answers[question.id] = selectedIndex; } // Update current question state if viewing it if (index == _currentQuestionIndex) { if (isMultipleCorrect) { _selectedAnswerIndices = selectedIndices; _selectedAnswerIndex = null; } else { _selectedAnswerIndex = selectedIndex; _selectedAnswerIndices.clear(); } } }); }, isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled && (isMultipleCorrect ? selectedIndices.contains(answerIndex) : selectedIndex == answerIndex) ? question.isCorrectAnswer(answerIndex) : 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, ); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('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, ); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Question marked as Needs Practice'), ), ); }, icon: const Icon(Icons.school), label: const Text('Needs Practice'), ), ), ], ), ], ), ), ), ], ), ), }, 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: FilledButton.icon( onPressed: _isLastQuestion ? (_hasAnswer ? _submitAnswer : null) : _goToNextQuestion, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24), minimumSize: const Size(0, 56), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), icon: Icon( _isLastQuestion ? Icons.check_circle : Icons.arrow_forward, size: 24, color: _isLastQuestion ? Colors.green : null, ), label: Text( _isLastQuestion ? 'Complete' : 'Next', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ), ), ], ), ), ), ], ), ), ); } }