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.
198 lines
6.8 KiB
198 lines
6.8 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
|
|
final shouldIncludeKnown = includeKnown ?? deck.config.includeKnownInAttempts;
|
|
var candidates = shouldIncludeKnown
|
|
? List<Question>.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,
|
|
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];
|
|
|
|
// Not answering or invalid format: treat same as incorrect
|
|
final List<int> userAnswerIndices;
|
|
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<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 {
|
|
userAnswerIndices = [];
|
|
isCorrect = false;
|
|
}
|
|
|
|
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)}';
|
|
}
|
|
}
|
|
|