From fe9c5a9fec80071e46916d9533edabcdb118c915 Mon Sep 17 00:00:00 2001 From: gitea Date: Sun, 1 Feb 2026 19:49:49 +0100 Subject: [PATCH] flagging and exclude --- lib/screens/deck_config_screen.dart | 18 +++++++++++ lib/screens/deck_import_screen.dart | 3 ++ lib/screens/deck_overview_screen.dart | 22 +++++++------ lib/services/deck_storage.dart | 2 ++ .../lib/logic/attempt_service.dart | 9 ++++-- packages/practice_engine/lib/models/deck.dart | 31 ++++++++++++------- .../lib/models/deck_config.dart | 10 ++++++ 7 files changed, 72 insertions(+), 23 deletions(-) diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart index ecfdb79..b6c6636 100644 --- a/lib/screens/deck_config_screen.dart +++ b/lib/screens/deck_config_screen.dart @@ -24,6 +24,7 @@ class _DeckConfigScreenState extends State { late bool _immediateFeedback; late bool _includeKnownInAttempts; late bool _shuffleAnswerOrder; + late bool _excludeFlaggedQuestions; bool _timeLimitEnabled = false; String? _lastDeckId; int _configHashCode = 0; @@ -74,6 +75,7 @@ class _DeckConfigScreenState extends State { _immediateFeedback = _config!.immediateFeedbackEnabled; _includeKnownInAttempts = _config!.includeKnownInAttempts; _shuffleAnswerOrder = _config!.shuffleAnswerOrder; + _excludeFlaggedQuestions = _config!.excludeFlaggedQuestions; // Initialize time limit controllers _timeLimitEnabled = _config!.timeLimitSeconds != null; @@ -196,6 +198,7 @@ class _DeckConfigScreenState extends State { immediateFeedbackEnabled: _immediateFeedback, includeKnownInAttempts: _includeKnownInAttempts, shuffleAnswerOrder: _shuffleAnswerOrder, + excludeFlaggedQuestions: _excludeFlaggedQuestions, timeLimitSeconds: timeLimitSeconds, ); @@ -416,6 +419,21 @@ class _DeckConfigScreenState extends State { ), const SizedBox(height: 16), + // Exclude Flagged Questions Toggle + SwitchListTile( + title: const Text('Exclude flagged questions'), + subtitle: const Text( + 'Flagged questions are not used in attempts and do not count in progress', + ), + value: _excludeFlaggedQuestions, + onChanged: (value) { + setState(() { + _excludeFlaggedQuestions = value; + }); + }, + ), + const SizedBox(height: 16), + // Time Limit Section SwitchListTile( title: const Text('Enable Time Limit'), diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart index 6b3f0ed..e457a4e 100644 --- a/lib/screens/deck_import_screen.dart +++ b/lib/screens/deck_import_screen.dart @@ -40,6 +40,7 @@ class _DeckImportScreenState extends State { immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true, + excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false, ); // Parse questions @@ -249,6 +250,7 @@ class _DeckImportScreenState extends State { 'immediateFeedbackEnabled': true, 'includeKnownInAttempts': false, 'shuffleAnswerOrder': true, + 'excludeFlaggedQuestions': false, }, 'questions': List.generate(20, (i) { return { @@ -545,6 +547,7 @@ class _DeckImportScreenState extends State { const Text('• immediateFeedbackEnabled: boolean'), const Text('• includeKnownInAttempts: boolean'), const Text('• shuffleAnswerOrder: boolean'), + const Text('• excludeFlaggedQuestions: boolean'), ], ), ), diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart index 9e8c953..af2c0e8 100644 --- a/lib/screens/deck_overview_screen.dart +++ b/lib/screens/deck_overview_screen.dart @@ -59,10 +59,14 @@ class _DeckOverviewScreenState extends State { } } - if (deckToCheck == null || deckToCheck.questions.isEmpty) { + if (deckToCheck == null || deckToCheck.activeQuestionCount == 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cannot start attempt: No questions in deck'), + SnackBar( + content: Text( + deckToCheck != null && deckToCheck.questions.isNotEmpty && deckToCheck.config.excludeFlaggedQuestions + ? 'Cannot start attempt: All questions are flagged. Unflag some or turn off "Exclude flagged questions" in settings.' + : 'Cannot start attempt: No questions in deck', + ), backgroundColor: Colors.red, ), ); @@ -158,8 +162,8 @@ class _DeckOverviewScreenState extends State { } } - // Check if all questions are known and includeKnownInAttempts is disabled - final allKnown = deckToCheck.knownCount == deckToCheck.numberOfQuestions && deckToCheck.numberOfQuestions > 0; + // Check if all active questions are known and includeKnownInAttempts is disabled + final allKnown = deckToCheck.knownCount == deckToCheck.activeQuestionCount && deckToCheck.activeQuestionCount > 0; final includeKnownDisabled = !deckToCheck.config.includeKnownInAttempts; if (allKnown && includeKnownDisabled) { @@ -285,7 +289,7 @@ class _DeckOverviewScreenState extends State { Widget _buildProgressStats(BuildContext context) { if (_deck == null) return const SizedBox.shrink(); - final totalQuestions = _deck!.numberOfQuestions; + final totalQuestions = _deck!.activeQuestionCount; final knownCount = _deck!.knownCount; final unknownCount = totalQuestions - knownCount; final unknownPercentage = totalQuestions > 0 @@ -484,7 +488,7 @@ class _DeckOverviewScreenState extends State { ), const SizedBox(height: 4), Text( - '${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known', + '${_deck!.knownCount} of ${_deck!.activeQuestionCount} questions known', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -505,7 +509,7 @@ class _DeckOverviewScreenState extends State { children: [ _StatItem( label: 'Total', - value: '${_deck!.numberOfQuestions}', + value: '${_deck!.activeQuestionCount}', icon: Icons.quiz, ), _StatItem( @@ -515,7 +519,7 @@ class _DeckOverviewScreenState extends State { ), _StatItem( label: 'Practice', - value: '${_deck!.numberOfQuestions - _deck!.knownCount}', + value: '${_deck!.activeQuestionCount - _deck!.knownCount}', icon: Icons.school, ), ], diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart index 925e7c1..c84c9d0 100644 --- a/lib/services/deck_storage.dart +++ b/lib/services/deck_storage.dart @@ -60,6 +60,7 @@ class DeckStorage { 'immediateFeedbackEnabled': deck.config.immediateFeedbackEnabled, 'includeKnownInAttempts': deck.config.includeKnownInAttempts, 'shuffleAnswerOrder': deck.config.shuffleAnswerOrder, + 'excludeFlaggedQuestions': deck.config.excludeFlaggedQuestions, 'timeLimitSeconds': deck.config.timeLimitSeconds, }, 'questions': deck.questions.map((q) => { @@ -108,6 +109,7 @@ class DeckStorage { immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true, + excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false, timeLimitSeconds: configJson['timeLimitSeconds'] as int?, ); diff --git a/packages/practice_engine/lib/logic/attempt_service.dart b/packages/practice_engine/lib/logic/attempt_service.dart index 348282c..1c4bc3e 100644 --- a/packages/practice_engine/lib/logic/attempt_service.dart +++ b/packages/practice_engine/lib/logic/attempt_service.dart @@ -27,11 +27,14 @@ class AttemptService { final size = attemptSize ?? deck.config.defaultAttemptSize; // Filter candidates based on includeKnownInAttempts setting - // Use override parameter if provided, otherwise use config final shouldIncludeKnown = includeKnown ?? deck.config.includeKnownInAttempts; - final candidates = shouldIncludeKnown - ? deck.questions + var candidates = shouldIncludeKnown + ? List.from(deck.questions) : deck.questions.where((q) => !q.isKnown).toList(); + // Exclude flagged questions when config says so + if (deck.config.excludeFlaggedQuestions) { + candidates = candidates.where((q) => !q.isFlagged).toList(); + } final selected = _selector.selectQuestions( candidates: candidates, diff --git a/packages/practice_engine/lib/models/deck.dart b/packages/practice_engine/lib/models/deck.dart index ea11085..a8926f2 100644 --- a/packages/practice_engine/lib/models/deck.dart +++ b/packages/practice_engine/lib/models/deck.dart @@ -67,8 +67,17 @@ class Deck { /// Total number of questions in the deck. int get numberOfQuestions => questions.length; - /// Number of questions marked as known. - int get knownCount => questions.where((q) => q.isKnown).length; + /// Questions that count for attempts and statistics (excludes flagged when config says so). + List get _activeQuestions => + config.excludeFlaggedQuestions + ? questions.where((q) => !q.isFlagged).toList() + : questions; + + /// Number of active questions (excludes flagged when [DeckConfig.excludeFlaggedQuestions] is true). + int get activeQuestionCount => _activeQuestions.length; + + /// Number of questions marked as known (among active questions only). + int get knownCount => _activeQuestions.where((q) => q.isKnown).length; /// Number of questions the user has flagged. int get flaggedCount => questions.where((q) => q.isFlagged).length; @@ -76,24 +85,24 @@ class Deck { /// Questions that the user has flagged. List get flaggedQuestions => questions.where((q) => q.isFlagged).toList(); - /// Practice percentage: (known / total) * 100 + /// Practice percentage: (known among active / active count) * 100 double get practicePercentage { - if (questions.isEmpty) return 0.0; - return (knownCount / questions.length) * 100.0; + final active = _activeQuestions; + if (active.isEmpty) return 0.0; + return (knownCount / active.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. + /// Progress percentage including partial credit over active questions only. double get progressPercentage { - if (questions.isEmpty) return 0.0; + final active = _activeQuestions; + if (active.isEmpty) return 0.0; final required = config.requiredConsecutiveCorrect; - final sum = questions.fold(0.0, (sum, q) { + final sum = active.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; + return (sum / active.length) * 100.0; } /// Number of completed attempts. diff --git a/packages/practice_engine/lib/models/deck_config.dart b/packages/practice_engine/lib/models/deck_config.dart index df6b180..3a4fb2f 100644 --- a/packages/practice_engine/lib/models/deck_config.dart +++ b/packages/practice_engine/lib/models/deck_config.dart @@ -23,6 +23,10 @@ class DeckConfig { /// When true, answer options appear in random order each attempt. final bool shuffleAnswerOrder; + /// Whether to exclude flagged questions from attempts and statistics. + /// When true, flagged questions are not selected for attempts and do not count in progress/known. + final bool excludeFlaggedQuestions; + /// Optional time limit for attempts in seconds. /// If null, no time limit is enforced. final int? timeLimitSeconds; @@ -35,6 +39,7 @@ class DeckConfig { this.immediateFeedbackEnabled = true, this.includeKnownInAttempts = false, this.shuffleAnswerOrder = true, + this.excludeFlaggedQuestions = false, this.timeLimitSeconds, }); @@ -47,6 +52,7 @@ class DeckConfig { bool? immediateFeedbackEnabled, bool? includeKnownInAttempts, bool? shuffleAnswerOrder, + bool? excludeFlaggedQuestions, int? timeLimitSeconds, }) { return DeckConfig( @@ -62,6 +68,8 @@ class DeckConfig { includeKnownInAttempts: includeKnownInAttempts ?? this.includeKnownInAttempts, shuffleAnswerOrder: shuffleAnswerOrder ?? this.shuffleAnswerOrder, + excludeFlaggedQuestions: + excludeFlaggedQuestions ?? this.excludeFlaggedQuestions, timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds, ); } @@ -78,6 +86,7 @@ class DeckConfig { immediateFeedbackEnabled == other.immediateFeedbackEnabled && includeKnownInAttempts == other.includeKnownInAttempts && shuffleAnswerOrder == other.shuffleAnswerOrder && + excludeFlaggedQuestions == other.excludeFlaggedQuestions && timeLimitSeconds == other.timeLimitSeconds; @override @@ -89,6 +98,7 @@ class DeckConfig { immediateFeedbackEnabled.hashCode ^ includeKnownInAttempts.hashCode ^ shuffleAnswerOrder.hashCode ^ + excludeFlaggedQuestions.hashCode ^ (timeLimitSeconds?.hashCode ?? 0); }