From 7ab2eb2ba407895953a7913187503aa89e8e9ec9 Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 31 Jan 2026 00:32:48 +0100 Subject: [PATCH] shufle order --- README.md | 17 ++ lib/screens/attempt_screen.dart | 155 ++++++++++-------- lib/screens/deck_config_screen.dart | 43 +++-- lib/screens/deck_create_screen.dart | 12 +- lib/screens/deck_edit_screen.dart | 12 +- lib/screens/deck_import_screen.dart | 60 ++++--- lib/screens/deck_list_screen.dart | 48 +++--- lib/screens/deck_overview_screen.dart | 20 +-- lib/services/deck_storage.dart | 2 + lib/utils/top_snackbar.dart | 45 +++++ .../lib/logic/attempt_service.dart | 30 ++-- packages/practice_engine/lib/models/deck.dart | 14 ++ .../lib/models/deck_config.dart | 9 + .../test/attempt_flow_test.dart | 46 ++++++ packages/practice_engine/test/deck_test.dart | 75 +++++++++ 15 files changed, 427 insertions(+), 161 deletions(-) create mode 100644 lib/utils/top_snackbar.dart diff --git a/README.md b/README.md index 3f24072..882df0c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,23 @@ dart pub get - Dart SDK >= 3.0.0 +## Quick Start + +From the project root, use this order of commands: + +```bash +# 1. Get dependencies +flutter pub get + +# 2. Run tests (package tests from packages/practice_engine) +cd packages/practice_engine && dart test && cd ../.. + +# 3. Run the app +flutter run +``` + +Use `flutter run -d ` to pick a device (e.g. `chrome` for web). Run `flutter devices` to list available targets. + ## Usage ### Creating a Deck diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart index a6ce4ad..884901b 100644 --- a/lib/screens/attempt_screen.dart +++ b/lib/screens/attempt_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/top_snackbar.dart'; import 'dart:math' as math; import 'dart:async'; import 'dart:ui'; @@ -77,6 +78,8 @@ class _AttemptScreenState extends State { 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; @@ -117,10 +120,13 @@ class _AttemptScreenState extends State { _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) { @@ -132,6 +138,16 @@ class _AttemptScreenState extends State { 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 @@ -451,12 +467,11 @@ class _AttemptScreenState extends State { final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt); _deckStorage.saveDeckSync(updatedDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Attempt saved. You can continue later.'), - backgroundColor: Colors.blue, - duration: Duration(seconds: 2), - ), + showTopSnackBar( + context, + message: 'Attempt saved. You can continue later.', + backgroundColor: Colors.blue, + duration: const Duration(seconds: 2), ); Navigator.pop(context); @@ -709,58 +724,62 @@ class _AttemptScreenState extends State { 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 { - // 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) { - 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, - ), - ), - ), + // 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), @@ -788,11 +807,10 @@ class _AttemptScreenState extends State { questionId: question.id, ); }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Question marked as Known'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Question marked as Known', + backgroundColor: Colors.green, ); }, icon: const Icon(Icons.check_circle), @@ -810,10 +828,9 @@ class _AttemptScreenState extends State { questionId: question.id, ); }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Question marked as Needs Practice'), - ), + showTopSnackBar( + context, + message: 'Question marked as Needs Practice', ); }, icon: const Icon(Icons.school), diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart index ad1beaa..ecfdb79 100644 --- a/lib/screens/deck_config_screen.dart +++ b/lib/screens/deck_config_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/top_snackbar.dart'; import 'package:practice_engine/practice_engine.dart'; import '../services/deck_storage.dart'; import '../routes.dart'; @@ -22,6 +23,7 @@ class _DeckConfigScreenState extends State { late TextEditingController _timeLimitSecondsController; late bool _immediateFeedback; late bool _includeKnownInAttempts; + late bool _shuffleAnswerOrder; bool _timeLimitEnabled = false; String? _lastDeckId; int _configHashCode = 0; @@ -71,7 +73,8 @@ class _DeckConfigScreenState extends State { ); _immediateFeedback = _config!.immediateFeedbackEnabled; _includeKnownInAttempts = _config!.includeKnownInAttempts; - + _shuffleAnswerOrder = _config!.shuffleAnswerOrder; + // Initialize time limit controllers _timeLimitEnabled = _config!.timeLimitSeconds != null; final totalSeconds = _config!.timeLimitSeconds ?? 0; @@ -192,18 +195,18 @@ class _DeckConfigScreenState extends State { priorityDecreaseOnCorrect: priorityDecrease, immediateFeedbackEnabled: _immediateFeedback, includeKnownInAttempts: _includeKnownInAttempts, + shuffleAnswerOrder: _shuffleAnswerOrder, timeLimitSeconds: timeLimitSeconds, ); final updatedDeck = _deck!.copyWith(config: updatedConfig); // Show success message - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deck configuration saved successfully'), - backgroundColor: Colors.green, - duration: Duration(seconds: 1), - ), + showTopSnackBar( + context, + message: 'Deck configuration saved successfully', + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), ); // Pop with the updated deck @@ -276,12 +279,11 @@ class _DeckConfigScreenState extends State { // Show success message if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deck has been reset successfully'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), + showTopSnackBar( + context, + message: 'Deck has been reset successfully', + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), ); } @@ -399,6 +401,21 @@ class _DeckConfigScreenState extends State { ), const SizedBox(height: 16), + // Shuffle Answer Order Toggle + SwitchListTile( + title: const Text('Shuffle Answer Order'), + subtitle: const Text( + 'Show answer options in random order each attempt', + ), + value: _shuffleAnswerOrder, + onChanged: (value) { + setState(() { + _shuffleAnswerOrder = value; + }); + }, + ), + const SizedBox(height: 16), + // Time Limit Section SwitchListTile( title: const Text('Enable Time Limit'), diff --git a/lib/screens/deck_create_screen.dart b/lib/screens/deck_create_screen.dart index 4605472..2210e25 100644 --- a/lib/screens/deck_create_screen.dart +++ b/lib/screens/deck_create_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/top_snackbar.dart'; import 'package:practice_engine/practice_engine.dart'; import '../services/deck_storage.dart'; import 'deck_edit_screen.dart'; @@ -98,12 +99,11 @@ class _DeckCreateScreenState extends State { _deckStorage.saveDeckSync(newDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deck created successfully'), - backgroundColor: Colors.green, - duration: Duration(seconds: 1), - ), + showTopSnackBar( + context, + message: 'Deck created successfully', + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), ); Navigator.pop(context, newDeck); diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart index 245bc0b..d8b20f9 100644 --- a/lib/screens/deck_edit_screen.dart +++ b/lib/screens/deck_edit_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../utils/top_snackbar.dart'; import 'package:practice_engine/practice_engine.dart'; import '../services/deck_storage.dart'; @@ -120,12 +121,11 @@ class _DeckEditScreenState extends State { _deckStorage.saveDeckSync(updatedDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Deck saved successfully'), - backgroundColor: Colors.green, - duration: Duration(seconds: 1), - ), + showTopSnackBar( + context, + message: 'Deck saved successfully', + backgroundColor: Colors.green, + duration: const Duration(seconds: 1), ); Navigator.pop(context, updatedDeck); diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart index 9f6cb75..ffcf8bc 100644 --- a/lib/screens/deck_import_screen.dart +++ b/lib/screens/deck_import_screen.dart @@ -1,8 +1,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:practice_engine/practice_engine.dart'; import '../data/default_deck.dart'; import '../services/deck_storage.dart'; +import '../utils/top_snackbar.dart'; class DeckImportScreen extends StatefulWidget { const DeckImportScreen({super.key}); @@ -35,6 +37,7 @@ class _DeckImportScreenState extends State { priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2, immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, + shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true, ); // Parse questions @@ -173,11 +176,10 @@ class _DeckImportScreenState extends State { } else if (action == 'replace') { deckStorage.saveDeckSync(defaultDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck replaced successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck replaced successfully', + backgroundColor: Colors.green, ); } else if (action == 'copy') { // Find the next copy number @@ -210,22 +212,20 @@ class _DeckImportScreenState extends State { ); deckStorage.saveDeckSync(copiedDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck copied successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck copied successfully', + backgroundColor: Colors.green, ); } } else { // Save default deck to storage deckStorage.saveDeckSync(defaultDeck); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck added successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck added successfully', + backgroundColor: Colors.green, ); } @@ -245,6 +245,7 @@ class _DeckImportScreenState extends State { 'priorityDecreaseOnCorrect': 2, 'immediateFeedbackEnabled': true, 'includeKnownInAttempts': false, + 'shuffleAnswerOrder': true, }, 'questions': List.generate(20, (i) { return { @@ -260,6 +261,16 @@ class _DeckImportScreenState extends State { _jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson); } + void _copyFieldToClipboard() { + final text = _jsonController.text.trim(); + if (text.isEmpty) { + showTopSnackBar(context, message: 'Nothing to copy'); + return; + } + Clipboard.setData(ClipboardData(text: text)); + showTopSnackBar(context, message: 'JSON copied to clipboard'); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -379,10 +390,20 @@ class _DeckImportScreenState extends State { 'Deck JSON', style: Theme.of(context).textTheme.titleMedium, ), - TextButton.icon( - onPressed: _loadSampleDeck, - icon: const Icon(Icons.description), - label: const Text('Load Sample'), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: _loadSampleDeck, + icon: const Icon(Icons.description), + label: const Text('Load Sample'), + ), + IconButton( + onPressed: _copyFieldToClipboard, + icon: const Icon(Icons.copy), + tooltip: 'Copy field contents', + ), + ], ), ], ), @@ -491,6 +512,7 @@ class _DeckImportScreenState extends State { const Text('• priorityDecreaseOnCorrect: number'), const Text('• immediateFeedbackEnabled: boolean'), const Text('• includeKnownInAttempts: boolean'), + const Text('• shuffleAnswerOrder: boolean'), ], ), ), diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart index 1c720bc..b87c6a3 100644 --- a/lib/screens/deck_list_screen.dart +++ b/lib/screens/deck_list_screen.dart @@ -3,6 +3,7 @@ import 'package:practice_engine/practice_engine.dart'; import '../routes.dart'; import '../services/deck_storage.dart'; import '../data/default_deck.dart'; +import '../utils/top_snackbar.dart'; class DeckListScreen extends StatefulWidget { const DeckListScreen({super.key}); @@ -73,11 +74,10 @@ class _DeckListScreenState extends State { _deckStorage.deleteDeckSync(deck.id); _loadDecks(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${deck.title} deleted'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: '${deck.title} deleted', + backgroundColor: Colors.green, ); } } @@ -125,11 +125,10 @@ class _DeckListScreenState extends State { _loadDecks(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${deck.title} cloned successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: '${deck.title} cloned successfully', + backgroundColor: Colors.green, ); } } @@ -242,11 +241,10 @@ class _DeckListScreenState extends State { _deckStorage.saveDeckSync(defaultDeck); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck replaced successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck replaced successfully', + backgroundColor: Colors.green, ); } } else if (action == 'copy') { @@ -281,11 +279,10 @@ class _DeckListScreenState extends State { _deckStorage.saveDeckSync(copiedDeck); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck copied successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck copied successfully', + backgroundColor: Colors.green, ); } } @@ -294,11 +291,10 @@ class _DeckListScreenState extends State { _deckStorage.saveDeckSync(defaultDeck); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Default deck added successfully'), - backgroundColor: Colors.green, - ), + showTopSnackBar( + context, + message: 'Default deck added successfully', + backgroundColor: Colors.green, ); } } @@ -495,7 +491,7 @@ class _DeckListScreenState extends State { Expanded( child: _StatChip( icon: Icons.trending_up, - label: '${deck.practicePercentage.toStringAsFixed(0)}%', + label: '${deck.progressPercentage.toStringAsFixed(0)}%', ), ), ], diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart index 7e8f4a1..beb4b8f 100644 --- a/lib/screens/deck_overview_screen.dart +++ b/lib/screens/deck_overview_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:practice_engine/practice_engine.dart'; import '../routes.dart'; import '../services/deck_storage.dart'; +import '../utils/top_snackbar.dart'; class DeckOverviewScreen extends StatefulWidget { const DeckOverviewScreen({super.key}); @@ -252,14 +253,13 @@ class _DeckOverviewScreenState extends State { // Show confirmation message if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(wasReset - ? 'Deck has been reset successfully' - : 'Deck settings have been updated'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), + showTopSnackBar( + context, + message: wasReset + ? 'Deck has been reset successfully' + : 'Deck settings have been updated', + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), ); } } @@ -467,7 +467,7 @@ class _DeckOverviewScreenState extends State { width: 80, height: 80, child: CircularProgressIndicator( - value: _deck!.practicePercentage / 100, + value: _deck!.progressPercentage / 100, strokeWidth: 6, ), ), @@ -477,7 +477,7 @@ class _DeckOverviewScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_deck!.practicePercentage.toStringAsFixed(1)}%', + '${_deck!.progressPercentage.toStringAsFixed(1)}%', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart index 138cec7..e393b1f 100644 --- a/lib/services/deck_storage.dart +++ b/lib/services/deck_storage.dart @@ -59,6 +59,7 @@ class DeckStorage { 'priorityDecreaseOnCorrect': deck.config.priorityDecreaseOnCorrect, 'immediateFeedbackEnabled': deck.config.immediateFeedbackEnabled, 'includeKnownInAttempts': deck.config.includeKnownInAttempts, + 'shuffleAnswerOrder': deck.config.shuffleAnswerOrder, 'timeLimitSeconds': deck.config.timeLimitSeconds, }, 'questions': deck.questions.map((q) => { @@ -105,6 +106,7 @@ class DeckStorage { priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2, immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, + shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true, timeLimitSeconds: configJson['timeLimitSeconds'] as int?, ); diff --git a/lib/utils/top_snackbar.dart b/lib/utils/top_snackbar.dart new file mode 100644 index 0000000..53df3f0 --- /dev/null +++ b/lib/utils/top_snackbar.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// Shows a snackbar-style message at the top of the screen (for success/neutral feedback). +void showTopSnackBar( + BuildContext context, { + required String message, + Color? backgroundColor, + Duration duration = const Duration(seconds: 2), +}) { + final overlay = Overlay.of(context); + final theme = Theme.of(context); + final color = backgroundColor ?? theme.colorScheme.surfaceContainerHighest; + final textColor = backgroundColor != null ? Colors.white : theme.colorScheme.onSurface; + + late OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Material( + color: color, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Text( + message, + style: theme.textTheme.bodyLarge?.copyWith(color: textColor), + ), + ), + ), + ), + ), + ), + ); + + overlay.insert(entry); + Future.delayed(duration, () { + entry.remove(); + }); +} diff --git a/packages/practice_engine/lib/logic/attempt_service.dart b/packages/practice_engine/lib/logic/attempt_service.dart index 741bbc7..348282c 100644 --- a/packages/practice_engine/lib/logic/attempt_service.dart +++ b/packages/practice_engine/lib/logic/attempt_service.dart @@ -68,9 +68,15 @@ class AttemptService { final answerResults = []; final updatedQuestions = []; - // Process each question in the attempt - for (final question in attempt.questions) { - final userAnswer = answers[question.id]; + // Process each question in the attempt. + // Use the deck's current question state (not the attempt snapshot) so that + // manual "mark as known" during the attempt is preserved when applying results. + for (final questionInAttempt in attempt.questions) { + final currentQuestion = deck.questions + .where((q) => q.id == questionInAttempt.id) + .firstOrNull ?? questionInAttempt; + + final userAnswer = answers[currentQuestion.id]; if (userAnswer == null) { // Question not answered, treat as incorrect continue; @@ -89,28 +95,28 @@ class AttemptService { // Check if answer is correct // For multiple correct answers: user must select all correct answers and no incorrect ones - final correctIndices = question.correctIndices; + final correctIndices = currentQuestion.correctIndices; final userSet = userAnswerIndices.toSet(); final correctSet = correctIndices.toSet(); - final isCorrect = userSet.length == correctSet.length && + final isCorrect = userSet.length == correctSet.length && userSet.every((idx) => correctSet.contains(idx)); - final userMarkedNeedsPractice = overrides[question.id] ?? false; + final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false; - // Determine status change - final oldIsKnown = question.isKnown; - final oldStreak = question.consecutiveCorrect; + // Determine status change (from current deck state) + final oldIsKnown = currentQuestion.isKnown; + final oldStreak = currentQuestion.consecutiveCorrect; - // Update question + // Update question from current deck state so manual marks are preserved final updated = userMarkedNeedsPractice ? DeckService.updateQuestionWithManualOverride( - question: question, + question: currentQuestion, isCorrect: isCorrect, userMarkedNeedsPractice: true, config: deck.config, currentAttemptIndex: deck.currentAttemptIndex, ) : DeckService.updateQuestionAfterAnswer( - question: question, + question: currentQuestion, isCorrect: isCorrect, config: deck.config, currentAttemptIndex: deck.currentAttemptIndex, diff --git a/packages/practice_engine/lib/models/deck.dart b/packages/practice_engine/lib/models/deck.dart index 9520e23..1b8ddff 100644 --- a/packages/practice_engine/lib/models/deck.dart +++ b/packages/practice_engine/lib/models/deck.dart @@ -76,6 +76,20 @@ class Deck { return (knownCount / questions.length) * 100.0; } + /// Progress percentage including partial credit: each question contributes + /// 1.0 if known, otherwise (consecutiveCorrect / requiredConsecutiveCorrect) + /// capped at 1.0. Averaged over all questions * 100. + double get progressPercentage { + if (questions.isEmpty) return 0.0; + final required = config.requiredConsecutiveCorrect; + final sum = questions.fold(0.0, (sum, q) { + if (q.isKnown) return sum + 1.0; + final partial = (q.consecutiveCorrect / required).clamp(0.0, 1.0); + return sum + partial; + }); + return (sum / questions.length) * 100.0; + } + /// Number of completed attempts. int get attemptCount => attemptHistory.length; diff --git a/packages/practice_engine/lib/models/deck_config.dart b/packages/practice_engine/lib/models/deck_config.dart index 542c4b8..df6b180 100644 --- a/packages/practice_engine/lib/models/deck_config.dart +++ b/packages/practice_engine/lib/models/deck_config.dart @@ -19,6 +19,10 @@ class DeckConfig { /// If false, known questions will be excluded from attempts. final bool includeKnownInAttempts; + /// Whether to shuffle answer order for each question in an attempt. + /// When true, answer options appear in random order each attempt. + final bool shuffleAnswerOrder; + /// Optional time limit for attempts in seconds. /// If null, no time limit is enforced. final int? timeLimitSeconds; @@ -30,6 +34,7 @@ class DeckConfig { this.priorityDecreaseOnCorrect = 2, this.immediateFeedbackEnabled = true, this.includeKnownInAttempts = false, + this.shuffleAnswerOrder = true, this.timeLimitSeconds, }); @@ -41,6 +46,7 @@ class DeckConfig { int? priorityDecreaseOnCorrect, bool? immediateFeedbackEnabled, bool? includeKnownInAttempts, + bool? shuffleAnswerOrder, int? timeLimitSeconds, }) { return DeckConfig( @@ -55,6 +61,7 @@ class DeckConfig { immediateFeedbackEnabled ?? this.immediateFeedbackEnabled, includeKnownInAttempts: includeKnownInAttempts ?? this.includeKnownInAttempts, + shuffleAnswerOrder: shuffleAnswerOrder ?? this.shuffleAnswerOrder, timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds, ); } @@ -70,6 +77,7 @@ class DeckConfig { priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect && immediateFeedbackEnabled == other.immediateFeedbackEnabled && includeKnownInAttempts == other.includeKnownInAttempts && + shuffleAnswerOrder == other.shuffleAnswerOrder && timeLimitSeconds == other.timeLimitSeconds; @override @@ -80,6 +88,7 @@ class DeckConfig { priorityDecreaseOnCorrect.hashCode ^ immediateFeedbackEnabled.hashCode ^ includeKnownInAttempts.hashCode ^ + shuffleAnswerOrder.hashCode ^ (timeLimitSeconds?.hashCode ?? 0); } diff --git a/packages/practice_engine/test/attempt_flow_test.dart b/packages/practice_engine/test/attempt_flow_test.dart index 4ae02e5..34c1360 100644 --- a/packages/practice_engine/test/attempt_flow_test.dart +++ b/packages/practice_engine/test/attempt_flow_test.dart @@ -3,6 +3,7 @@ import 'package:practice_engine/models/deck.dart'; import 'package:practice_engine/models/deck_config.dart'; import 'package:practice_engine/models/question.dart'; import 'package:practice_engine/logic/attempt_service.dart'; +import 'package:practice_engine/logic/deck_service.dart'; void main() { group('Attempt Flow', () { @@ -152,6 +153,51 @@ void main() { expect(updated.consecutiveCorrect, equals(3)); expect(updated.isKnown, equals(true)); }); + + test('processAttempt preserves manual mark as known when using current deck state', () { + // One question, not known, no streak + final question = Question( + id: 'q1', + prompt: 'Test', + answers: ['A', 'B'], + correctAnswerIndices: [0], + consecutiveCorrect: 0, + isKnown: false, + ); + + final testDeck = Deck( + id: 'deck1', + title: 'Test', + description: 'Test', + questions: [question], + config: const DeckConfig(requiredConsecutiveCorrect: 3), + ); + + final attempt = attemptService.createAttempt( + deck: testDeck, + attemptSize: 1, + ); + expect(attempt.questions.single.isKnown, isFalse); + + // User marks as known during the attempt (as in the app) + final deckWithManualKnown = DeckService.markQuestionAsKnown( + deck: testDeck, + questionId: question.id, + ); + expect(deckWithManualKnown.questions.single.isKnown, isTrue); + + // Complete attempt with correct answer, passing deck that has manual known + final answers = {question.id: question.correctIndices.first}; + final result = attemptService.processAttempt( + deck: deckWithManualKnown, + attempt: attempt, + answers: answers, + ); + + // Manual "known" must be preserved in the result + final updated = result.updatedDeck.questions.first; + expect(updated.isKnown, isTrue, reason: 'Manual mark as known must count towards known after attempt'); + }); }); } diff --git a/packages/practice_engine/test/deck_test.dart b/packages/practice_engine/test/deck_test.dart index b7544c3..1abac72 100644 --- a/packages/practice_engine/test/deck_test.dart +++ b/packages/practice_engine/test/deck_test.dart @@ -96,6 +96,81 @@ void main() { expect(deck.practicePercentage, equals(100.0)); }); + test('progressPercentage is 0 for empty deck', () { + final deck = Deck( + id: 'deck1', + title: 'Empty Deck', + description: 'No questions', + questions: [], + config: defaultConfig, + ); + + expect(deck.progressPercentage, equals(0.0)); + }); + + test('progressPercentage equals practicePercentage when no partial streak', () { + final deck = Deck( + id: 'deck1', + title: 'Math Deck', + description: 'Basic math', + questions: sampleQuestions, + config: defaultConfig, + ); + + expect(deck.progressPercentage, closeTo(deck.practicePercentage, 0.01)); + }); + + test('progressPercentage is 100 when all questions are known', () { + final allKnown = sampleQuestions.map((q) => q.copyWith(isKnown: true)).toList(); + final deck = Deck( + id: 'deck1', + title: 'All Known', + description: 'All known', + questions: allKnown, + config: defaultConfig, + ); + + expect(deck.progressPercentage, equals(100.0)); + }); + + test('progressPercentage includes partial credit from consecutiveCorrect', () { + // requiredConsecutiveCorrect is 3 (default). 3 questions: none known, each at 2/3 + final withPartial = [ + Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false), + Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false), + Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false), + ]; + final deck = Deck( + id: 'deck1', + title: 'Partial', + description: 'Partial progress', + questions: withPartial, + config: defaultConfig, + ); + + expect(deck.practicePercentage, equals(0.0)); + expect(deck.progressPercentage, closeTo(66.67, 0.01)); // 3 * (2/3) / 3 * 100 + }); + + test('progressPercentage mixes known and partial correctly', () { + final config = DeckConfig(requiredConsecutiveCorrect: 3); + final questions = [ + Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], isKnown: true), + Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false), + Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 0, isKnown: false), + ]; + final deck = Deck( + id: 'deck1', + title: 'Mixed', + description: 'Mixed', + questions: questions, + config: config, + ); + + // 1.0 + 2/3 + 0 = 1.667; 1.667/3*100 ≈ 55.56 + expect(deck.progressPercentage, closeTo(55.56, 0.01)); + }); + test('copyWith creates new deck with updated fields', () { final deck = Deck( id: 'deck1',