From 1ae9413c715688053411c55cf49588c3d45f0137 Mon Sep 17 00:00:00 2001 From: gitea Date: Sun, 1 Feb 2026 22:21:17 +0100 Subject: [PATCH] improvements in editing questions --- lib/screens/attempt_result_screen.dart | 30 +++- lib/screens/attempt_screen.dart | 40 ++++- lib/screens/deck_config_screen.dart | 141 +++++++++++++----- lib/screens/deck_edit_screen.dart | 46 ++++++ .../lib/logic/attempt_service.dart | 33 ++-- 5 files changed, 226 insertions(+), 64 deletions(-) diff --git a/lib/screens/attempt_result_screen.dart b/lib/screens/attempt_result_screen.dart index a15d1af..2b38d3a 100644 --- a/lib/screens/attempt_result_screen.dart +++ b/lib/screens/attempt_result_screen.dart @@ -15,6 +15,7 @@ class AttemptResultScreen extends StatefulWidget { class _AttemptResultScreenState extends State { Deck? _deck; AttemptResult? _result; + Attempt? _completedAttempt; final DeckStorage _deckStorage = DeckStorage(); @override @@ -30,6 +31,7 @@ class _AttemptResultScreenState extends State { final args = ModalRoute.of(context)?.settings.arguments as Map?; _deck = args?['deck'] as Deck? ?? _createSampleDeck(); _result = args?['result'] as AttemptResult? ?? _createSampleResult(); + _completedAttempt = args?['attempt'] as Attempt?; } } @@ -63,7 +65,16 @@ class _AttemptResultScreenState extends State { void _repeatSameAttempt() { if (_deck == null) return; - Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck); + // Pass the completed attempt so the attempt screen uses the same questions in the same order + if (_completedAttempt != null && _completedAttempt!.questions.isNotEmpty) { + Navigator.pushReplacementNamed( + context, + Routes.attempt, + arguments: {'deck': _deck, 'repeatAttempt': _completedAttempt}, + ); + } else { + Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck); + } } void _newAttempt() { @@ -75,11 +86,12 @@ class _AttemptResultScreenState extends State { if (_deck == null) return; // Save the updated deck to storage _deckStorage.saveDeckSync(_deck!); - // Navigate back to deck list - Navigator.pushNamedAndRemoveUntil( + // Navigate back to this deck's overview (decks screen), not the home deck list + Navigator.popUntil(context, (route) => route.settings.name == Routes.deckOverview); + Navigator.pushReplacementNamed( context, - Routes.deckList, - (route) => false, + Routes.deckOverview, + arguments: _deck, ); } @@ -273,7 +285,9 @@ class _AttemptResultScreenState extends State { StatusChip(statusChange: answerResult.statusChange), const Spacer(), Text( - 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', + answerResult.userAnswerIndices.isEmpty + ? 'No answer' + : 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', style: TextStyle( color: Colors.red, fontWeight: FontWeight.bold, @@ -332,7 +346,9 @@ class _AttemptResultScreenState extends State { subtitle: Text( answerResult.isCorrect ? 'Correct' - : 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', + : answerResult.userAnswerIndices.isEmpty + ? 'Incorrect - No answer' + : 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', ), trailing: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart index 546c82c..ca0a1ef 100644 --- a/lib/screens/attempt_screen.dart +++ b/lib/screens/attempt_screen.dart @@ -101,6 +101,8 @@ class _AttemptScreenState extends State { bool? includeKnown; bool? resumeAttempt; + final repeatAttempt = args is Map ? args['repeatAttempt'] as Attempt? : null; + if (args is Map) { _deck = args['deck'] as Deck? ?? _createSampleDeck(); includeKnown = args['includeKnown'] as bool?; @@ -112,9 +114,36 @@ class _AttemptScreenState extends State { _deck = _createSampleDeck(); resumeAttempt = false; } - - // Check if we should resume an incomplete attempt - if (resumeAttempt == true && _deck!.incompleteAttempt != null) { + + // Check if we should repeat the exact same attempt (same questions, same order) + if (repeatAttempt != null && repeatAttempt.questions.isNotEmpty) { + final orderedIds = repeatAttempt.questions.map((q) => q.id).toList(); + final questions = []; + for (final id in orderedIds) { + final q = _deck!.questions.where((q) => q.id == id).firstOrNull; + if (q != null) questions.add(q); + } + if (questions.isNotEmpty) { + _attempt = Attempt( + id: 'repeat-${DateTime.now().millisecondsSinceEpoch}', + questions: questions, + startTime: DateTime.now().millisecondsSinceEpoch, + ); + // Same as new attempt: randomize answer order per question when 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; + } + } + } + + // If not repeating, check if we should resume an incomplete attempt + if (_attempt == null && resumeAttempt == true && _deck!.incompleteAttempt != null) { final incomplete = _deck!.incompleteAttempt!; _attempt = incomplete.toAttempt(_deck!.questions); _currentQuestionIndex = incomplete.currentQuestionIndex; @@ -133,7 +162,10 @@ class _AttemptScreenState extends State { _pageController.jumpToPage(_currentQuestionIndex); } }); - } else { + } + + // Otherwise create a new attempt + if (_attempt == null) { _attempt = _attemptService!.createAttempt( deck: _deck!, includeKnown: includeKnown, diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart index b6c6636..e1f1934 100644 --- a/lib/screens/deck_config_screen.dart +++ b/lib/screens/deck_config_screen.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../utils/top_snackbar.dart'; import 'package:practice_engine/practice_engine.dart'; import '../services/deck_storage.dart'; @@ -135,8 +138,10 @@ class _DeckConfigScreenState extends State { super.dispose(); } - void _save() { - if (_deck == null || _config == null) return; + /// Builds config from current form state, validates, persists if valid. + /// Returns true if saved, false if validation failed. + bool _applyAndPersist() { + if (_deck == null || _config == null) return false; final consecutive = int.tryParse(_consecutiveController.text); final attemptSize = int.tryParse(_attemptSizeController.text); @@ -153,7 +158,7 @@ class _DeckConfigScreenState extends State { backgroundColor: Colors.red, ), ); - return; + return false; } if (consecutive < 1 || @@ -166,7 +171,7 @@ class _DeckConfigScreenState extends State { backgroundColor: Colors.red, ), ); - return; + return false; } // Calculate time limit in seconds @@ -176,7 +181,7 @@ class _DeckConfigScreenState extends State { final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0; final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0; final totalSeconds = hours * 3600 + minutes * 60 + seconds; - + if (totalSeconds > 0) { timeLimitSeconds = totalSeconds; } else { @@ -186,7 +191,7 @@ class _DeckConfigScreenState extends State { backgroundColor: Colors.red, ), ); - return; + return false; } } @@ -203,21 +208,78 @@ class _DeckConfigScreenState extends State { ); final updatedDeck = _deck!.copyWith(config: updatedConfig); + final deckStorage = DeckStorage(); + deckStorage.saveDeckSync(updatedDeck); - // Show success message - showTopSnackBar( - context, - message: 'Deck configuration saved successfully', - backgroundColor: Colors.green, - duration: const Duration(seconds: 1), - ); + setState(() { + _deck = updatedDeck; + _config = updatedConfig; + _configHashCode = updatedConfig.hashCode; + }); + return true; + } - // Pop with the updated deck - Navigator.pop(context, updatedDeck); + Map _configToJsonMap() { + int? timeLimitSeconds; + if (_timeLimitEnabled) { + final hours = int.tryParse(_timeLimitHoursController.text) ?? 0; + final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0; + final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0; + timeLimitSeconds = hours * 3600 + minutes * 60 + seconds; + if (timeLimitSeconds == 0) timeLimitSeconds = null; + } + return { + 'requiredConsecutiveCorrect': int.tryParse(_consecutiveController.text) ?? _config!.requiredConsecutiveCorrect, + 'defaultAttemptSize': int.tryParse(_attemptSizeController.text) ?? _config!.defaultAttemptSize, + 'priorityIncreaseOnIncorrect': int.tryParse(_priorityIncreaseController.text) ?? _config!.priorityIncreaseOnIncorrect, + 'priorityDecreaseOnCorrect': int.tryParse(_priorityDecreaseController.text) ?? _config!.priorityDecreaseOnCorrect, + 'immediateFeedbackEnabled': _immediateFeedback, + 'includeKnownInAttempts': _includeKnownInAttempts, + 'shuffleAnswerOrder': _shuffleAnswerOrder, + 'excludeFlaggedQuestions': _excludeFlaggedQuestions, + if (timeLimitSeconds != null) 'timeLimitSeconds': timeLimitSeconds, + }; } - void _cancel() { - Navigator.pop(context); + void _showAsJson() { + if (_deck == null || _config == null) return; + final map = _configToJsonMap(); + final jsonString = const JsonEncoder.withIndent(' ').convert(map); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Attempt Settings as JSON'), + content: SingleChildScrollView( + child: SelectableText( + jsonString, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + FilledButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: jsonString)); + Navigator.pop(context); + showTopSnackBar( + context, + message: 'JSON copied to clipboard', + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), + ); + }, + icon: const Icon(Icons.copy, size: 20), + label: const Text('Copy'), + ), + ], + ), + ); } void _editDeck() { @@ -307,7 +369,18 @@ class _DeckConfigScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Deck Configuration'), + title: const Text('Attempt Settings'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context, _deck), + ), + actions: [ + IconButton( + icon: const Icon(Icons.code), + onPressed: _showAsJson, + tooltip: 'Show as JSON', + ), + ], ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -335,6 +408,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), const SizedBox(height: 16), @@ -347,6 +421,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), const SizedBox(height: 16), @@ -359,6 +434,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), const SizedBox(height: 16), @@ -371,6 +447,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), const SizedBox(height: 16), @@ -385,6 +462,7 @@ class _DeckConfigScreenState extends State { setState(() { _immediateFeedback = value; }); + _applyAndPersist(); }, ), const SizedBox(height: 16), @@ -400,6 +478,7 @@ class _DeckConfigScreenState extends State { setState(() { _includeKnownInAttempts = value; }); + _applyAndPersist(); }, ), const SizedBox(height: 16), @@ -415,6 +494,7 @@ class _DeckConfigScreenState extends State { setState(() { _shuffleAnswerOrder = value; }); + _applyAndPersist(); }, ), const SizedBox(height: 16), @@ -430,6 +510,7 @@ class _DeckConfigScreenState extends State { setState(() { _excludeFlaggedQuestions = value; }); + _applyAndPersist(); }, ), const SizedBox(height: 16), @@ -450,6 +531,7 @@ class _DeckConfigScreenState extends State { _timeLimitSecondsController.clear(); } }); + _applyAndPersist(); }, ), if (_timeLimitEnabled) ...[ @@ -464,6 +546,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), ), const SizedBox(width: 8), @@ -475,6 +558,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), ), const SizedBox(width: 8), @@ -486,6 +570,7 @@ class _DeckConfigScreenState extends State { border: OutlineInputBorder(), ), keyboardType: TextInputType.number, + onEditingComplete: _applyAndPersist, ), ), ], @@ -579,26 +664,6 @@ class _DeckConfigScreenState extends State { ), ), ), - const SizedBox(height: 24), - - // Action Buttons - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: _cancel, - child: const Text('Cancel'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton( - onPressed: _save, - child: const Text('Save'), - ), - ), - ], - ), ], ), ), diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart index 5943db9..6dadbc8 100644 --- a/lib/screens/deck_edit_screen.dart +++ b/lib/screens/deck_edit_screen.dart @@ -16,6 +16,8 @@ class _DeckEditScreenState extends State { late TextEditingController _descriptionController; final List _questionEditors = []; final DeckStorage _deckStorage = DeckStorage(); + final ScrollController _scrollController = ScrollController(); + int? _focusNewQuestionIndex; @override void initState() { @@ -48,6 +50,7 @@ class _DeckEditScreenState extends State { @override void dispose() { + _scrollController.dispose(); _titleController.dispose(); _descriptionController.dispose(); for (final editor in _questionEditors) { @@ -59,6 +62,21 @@ class _DeckEditScreenState extends State { void _addQuestion() { setState(() { _questionEditors.add(QuestionEditor.empty()); + _focusNewQuestionIndex = _questionEditors.length - 1; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final pos = _scrollController.position; + if (pos.maxScrollExtent > pos.pixels) { + _scrollController.animateTo( + pos.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + Future.microtask(() { + if (mounted) setState(() => _focusNewQuestionIndex = null); + }); }); } @@ -156,6 +174,7 @@ class _DeckEditScreenState extends State { ], ), body: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -197,6 +216,7 @@ class _DeckEditScreenState extends State { onDelete: () => _removeQuestion(index), onUnflag: null, onChanged: () => setState(() {}), + requestFocusOnPrompt: _focusNewQuestionIndex == index, ); }), @@ -243,6 +263,7 @@ class QuestionEditorCard extends StatefulWidget { final VoidCallback? onDelete; final VoidCallback? onUnflag; final VoidCallback onChanged; + final bool requestFocusOnPrompt; const QuestionEditorCard({ super.key, @@ -251,6 +272,7 @@ class QuestionEditorCard extends StatefulWidget { this.onDelete, this.onUnflag, required this.onChanged, + this.requestFocusOnPrompt = false, }); @override @@ -258,8 +280,31 @@ class QuestionEditorCard extends StatefulWidget { } class _QuestionEditorCardState extends State { + final FocusNode _promptFocusNode = FocusNode(); + + @override + void dispose() { + _promptFocusNode.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(QuestionEditorCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.requestFocusOnPrompt && !oldWidget.requestFocusOnPrompt) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _promptFocusNode.requestFocus(); + }); + } + } + @override Widget build(BuildContext context) { + if (widget.requestFocusOnPrompt) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _promptFocusNode.requestFocus(); + }); + } return Card( margin: const EdgeInsets.only(bottom: 16), child: Padding( @@ -297,6 +342,7 @@ class _QuestionEditorCardState extends State { // Question Prompt TextField( controller: widget.editor.promptController, + focusNode: _promptFocusNode, decoration: const InputDecoration( labelText: 'Question', border: OutlineInputBorder(), diff --git a/packages/practice_engine/lib/logic/attempt_service.dart b/packages/practice_engine/lib/logic/attempt_service.dart index 1c4bc3e..c7f0b92 100644 --- a/packages/practice_engine/lib/logic/attempt_service.dart +++ b/packages/practice_engine/lib/logic/attempt_service.dart @@ -80,29 +80,32 @@ class AttemptService { .firstOrNull ?? questionInAttempt; final userAnswer = answers[currentQuestion.id]; - if (userAnswer == null) { - // Question not answered, treat as incorrect - continue; - } - // Handle both single answer (int) and multiple answers (List) + // Not answering or invalid format: treat same as incorrect final List userAnswerIndices; - if (userAnswer is int) { + final bool isCorrect; + if (userAnswer == null) { + userAnswerIndices = []; + isCorrect = false; + } else if (userAnswer is int) { userAnswerIndices = [userAnswer]; + final correctIndices = currentQuestion.correctIndices; + final userSet = userAnswerIndices.toSet(); + final correctSet = correctIndices.toSet(); + isCorrect = userSet.length == correctSet.length && + userSet.every((idx) => correctSet.contains(idx)); } else if (userAnswer is List) { userAnswerIndices = userAnswer; + final correctIndices = currentQuestion.correctIndices; + final userSet = userAnswerIndices.toSet(); + final correctSet = correctIndices.toSet(); + isCorrect = userSet.length == correctSet.length && + userSet.every((idx) => correctSet.contains(idx)); } else { - // Invalid format, treat as incorrect - continue; + userAnswerIndices = []; + isCorrect = false; } - // Check if answer is correct - // For multiple correct answers: user must select all correct answers and no incorrect ones - final correctIndices = currentQuestion.correctIndices; - final userSet = userAnswerIndices.toSet(); - final correctSet = correctIndices.toSet(); - final isCorrect = userSet.length == correctSet.length && - userSet.every((idx) => correctSet.contains(idx)); final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false; // Determine status change (from current deck state)