You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

192 lines
6.5 KiB

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<int> (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<String, dynamic> answers,
Map<String, bool>? manualOverrides,
int? endTime,
}) {
final overrides = manualOverrides ?? {};
final finishTime = endTime ?? DateTime.now().millisecondsSinceEpoch;
final timeSpent = finishTime - attempt.startTime;
final answerResults = <AnswerResult>[];
final updatedQuestions = <Question>[];
// 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<int>)
final List<int> userAnswerIndices;
if (userAnswer is int) {
userAnswerIndices = [userAnswer];
} else if (userAnswer is List<int>) {
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)}';
}
}

Powered by TurnKey Linux.