diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3b88ad4..7f96acc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ { late PageController _pageController; Timer? _timer; int _remainingSeconds = 0; - int _startTime = 0; @override void initState() { @@ -148,7 +147,6 @@ class _AttemptScreenState extends State { _remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!); } } else { - _startTime = DateTime.now().millisecondsSinceEpoch; _remainingSeconds = _deck!.config.timeLimitSeconds!; } _startTimer(); @@ -222,6 +220,39 @@ class _AttemptScreenState extends State { /// 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) { @@ -315,6 +346,82 @@ class _AttemptScreenState extends State { _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; @@ -448,12 +555,19 @@ class _AttemptScreenState extends State { appBar: AppBar( title: Text('Attempt - ${_deck!.title}'), actions: [ + // Show "Jump to First Unanswered" if there are unanswered questions + if (_hasUnansweredQuestions) + IconButton( + onPressed: _jumpToFirstUnanswered, + icon: const Icon(Icons.skip_next), + tooltip: 'Jump to first unanswered question', + ), // Only show "Continue Later" button if there's progress if (_hasAnyProgress) - TextButton.icon( + IconButton( onPressed: _saveForLater, icon: const Icon(Icons.pause), - label: const Text('Continue Later'), + tooltip: 'Continue Later', ), ], ), @@ -614,8 +728,14 @@ class _AttemptScreenState extends State { } _answers[question.id] = selectedIndices.toList()..sort(); } else { - selectedIndex = answerIndex; - _answers[question.id] = selectedIndex; + // Toggle: if already selected, deselect; otherwise select + if (selectedIndex == answerIndex) { + selectedIndex = null; + _answers.remove(question.id); + } else { + selectedIndex = answerIndex; + _answers[question.id] = selectedIndex; + } } // Update current question state if viewing it if (index == _currentQuestionIndex) { @@ -707,10 +827,11 @@ class _AttemptScreenState extends State { ], ), ), + ); // Close AnimatedBuilder }, - pageSnapping: true, - ), - ), + pageSnapping: true, + ), + ), // Navigation Buttons Container( @@ -753,31 +874,9 @@ class _AttemptScreenState extends State { 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, - ), - ), - ), + child: _isLastQuestion + ? _buildCompleteButton() + : _buildNextButton(), ), ], ), @@ -788,5 +887,65 @@ class _AttemptScreenState extends State { ), ); } + + 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, + ), + ), + ); + } } diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart index f16e45c..2d8908a 100644 --- a/lib/screens/deck_list_screen.dart +++ b/lib/screens/deck_list_screen.dart @@ -221,7 +221,7 @@ class _DeckListScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Decky'), + title: const Text('omotomo'), actions: [ IconButton( icon: const Icon(Icons.add),