import 'dart:math'; import '../models/deck.dart'; import '../models/question.dart'; import '../models/attempt.dart'; import '../models/attempt_result.dart'; import '../algorithms/weighted_selector.dart'; import 'deck_service.dart'; /// Service for managing attempt/quiz operations. class AttemptService { final WeightedSelector _selector; /// Creates an attempt service with an optional random seed. AttemptService({int? seed}) : _selector = WeightedSelector(seed: seed); /// Creates a new attempt by selecting questions from the deck. /// /// Selects [attemptSize] questions (defaults to deck config default). /// Uses weighted random selection with no duplicates. /// Excludes known questions unless [DeckConfig.includeKnownInAttempts] is true. /// [includeKnown] can override the config setting for this attempt. Attempt createAttempt({ required Deck deck, int? attemptSize, bool? includeKnown, }) { 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 : deck.questions.where((q) => !q.isKnown).toList(); final selected = _selector.selectQuestions( candidates: candidates, count: size, currentAttemptIndex: deck.currentAttemptIndex, ); return Attempt( id: _generateAttemptId(), questions: selected, startTime: DateTime.now().millisecondsSinceEpoch, ); } /// Processes an attempt result and updates the deck. /// /// [answers] is a map of questionId -> userAnswerIndex (for single answer) or questionId -> List (for multiple answers). /// [manualOverrides] is an optional map of questionId -> bool (true if marked as needs practice). /// Returns the updated deck and attempt result. ({ Deck updatedDeck, AttemptResult result, }) processAttempt({ required Deck deck, required Attempt attempt, required Map answers, Map? manualOverrides, int? endTime, }) { final overrides = manualOverrides ?? {}; final finishTime = endTime ?? DateTime.now().millisecondsSinceEpoch; final timeSpent = finishTime - attempt.startTime; final answerResults = []; final updatedQuestions = []; // 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; } // Handle both single answer (int) and multiple answers (List) final List userAnswerIndices; if (userAnswer is int) { userAnswerIndices = [userAnswer]; } else if (userAnswer is List) { userAnswerIndices = userAnswer; } else { // Invalid format, treat as incorrect continue; } // 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) final oldIsKnown = currentQuestion.isKnown; final oldStreak = currentQuestion.consecutiveCorrect; // Update question from current deck state so manual marks are preserved final updated = userMarkedNeedsPractice ? DeckService.updateQuestionWithManualOverride( question: currentQuestion, isCorrect: isCorrect, userMarkedNeedsPractice: true, config: deck.config, currentAttemptIndex: deck.currentAttemptIndex, ) : DeckService.updateQuestionAfterAnswer( question: currentQuestion, isCorrect: isCorrect, config: deck.config, currentAttemptIndex: deck.currentAttemptIndex, ); // Determine status change QuestionStatusChange statusChange; if (userMarkedNeedsPractice) { statusChange = QuestionStatusChange.unchanged; } else if (isCorrect) { if (!oldIsKnown && updated.isKnown) { statusChange = QuestionStatusChange.improved; } else if (updated.consecutiveCorrect > oldStreak) { statusChange = QuestionStatusChange.improved; } else { statusChange = QuestionStatusChange.unchanged; } } else { if (oldIsKnown && !updated.isKnown) { statusChange = QuestionStatusChange.regressed; } else if (oldStreak > 0 && updated.consecutiveCorrect == 0) { statusChange = QuestionStatusChange.regressed; } else { statusChange = QuestionStatusChange.unchanged; } } answerResults.add(AnswerResult( question: updated, userAnswerIndices: userAnswerIndices, isCorrect: isCorrect, statusChange: statusChange, )); updatedQuestions.add(updated); } // Update deck with new question states final questionMap = Map.fromEntries( deck.questions.map((q) => MapEntry(q.id, q)), ); for (final updated in updatedQuestions) { questionMap[updated.id] = updated; } final allUpdatedQuestions = deck.questions.map((q) { return questionMap[q.id] ?? q; }).toList(); final updatedDeck = deck.copyWith( questions: allUpdatedQuestions, currentAttemptIndex: deck.currentAttemptIndex + 1, ); final result = AttemptResult.fromAnswers( results: answerResults, timeSpent: timeSpent, ); return ( updatedDeck: updatedDeck, result: result, ); } /// Generates a unique attempt ID. String _generateAttemptId() { return 'attempt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; } }