merge with practice_engine
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import '../models/question.dart';
|
||||
import '../models/deck_config.dart';
|
||||
|
||||
/// Manages priority point calculations for questions.
|
||||
class PriorityManager {
|
||||
/// Applies priority changes based on answer correctness.
|
||||
static Question applyAnswerResult({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required DeckConfig config,
|
||||
}) {
|
||||
if (isCorrect) {
|
||||
final newPriority = (question.priorityPoints -
|
||||
config.priorityDecreaseOnCorrect)
|
||||
.clamp(0, double.infinity)
|
||||
.toInt();
|
||||
return question.withPriorityPoints(newPriority);
|
||||
} else {
|
||||
return question.copyWith(
|
||||
priorityPoints: question.priorityPoints +
|
||||
config.priorityIncreaseOnIncorrect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets priority to 0.
|
||||
static Question resetPriority(Question question) {
|
||||
return question.withPriorityPoints(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import '../models/question.dart';
|
||||
|
||||
/// Handles spaced repetition logic for known questions.
|
||||
class SpacedRepetition {
|
||||
/// Base probability for known questions (very low).
|
||||
static const double baseKnownProbability = 0.01;
|
||||
|
||||
/// Maximum probability for known questions.
|
||||
static const double maxKnownProbability = 0.15;
|
||||
|
||||
/// Calculates the probability weight for a known question based on
|
||||
/// how long it's been since it was last seen.
|
||||
///
|
||||
/// [lastAttemptIndex] is the last attempt where the question was seen.
|
||||
/// [currentAttemptIndex] is the current global attempt index.
|
||||
static double calculateKnownQuestionWeight({
|
||||
required int lastAttemptIndex,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (lastAttemptIndex < 0) {
|
||||
// Never seen before, use base probability
|
||||
return baseKnownProbability;
|
||||
}
|
||||
|
||||
final attemptsSinceLastSeen = currentAttemptIndex - lastAttemptIndex;
|
||||
|
||||
// Probability increases with attempts since last seen
|
||||
// Formula: base + (min(attemptsSinceLastSeen / 20, 1) * (max - base))
|
||||
final progress = (attemptsSinceLastSeen / 20.0).clamp(0.0, 1.0);
|
||||
final probability = baseKnownProbability +
|
||||
(progress * (maxKnownProbability - baseKnownProbability));
|
||||
|
||||
return probability;
|
||||
}
|
||||
|
||||
/// Gets the weight for a question considering its known status and
|
||||
/// last attempt index.
|
||||
static double getQuestionWeight({
|
||||
required Question question,
|
||||
required int currentAttemptIndex,
|
||||
required double basePriorityWeight,
|
||||
}) {
|
||||
if (question.isKnown) {
|
||||
// Known questions use spaced repetition probability
|
||||
return calculateKnownQuestionWeight(
|
||||
lastAttemptIndex: question.lastAttemptIndex,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
) * basePriorityWeight;
|
||||
} else {
|
||||
// Unknown questions use priority-based weight
|
||||
// Priority points + 1 to ensure non-zero weight
|
||||
return (question.priorityPoints + 1).toDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:math';
|
||||
import '../models/question.dart';
|
||||
import 'spaced_repetition.dart';
|
||||
|
||||
/// Selects questions using weighted random selection.
|
||||
class WeightedSelector {
|
||||
final Random _random;
|
||||
|
||||
/// Creates a weighted selector with an optional random seed.
|
||||
WeightedSelector({int? seed}) : _random = Random(seed);
|
||||
|
||||
/// Selects [count] questions from [candidates] using weighted random selection.
|
||||
///
|
||||
/// [currentAttemptIndex] is used for spaced repetition calculations.
|
||||
/// Returns a list of selected questions (no duplicates).
|
||||
List<Question> selectQuestions({
|
||||
required List<Question> candidates,
|
||||
required int count,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (candidates.isEmpty || count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (count >= candidates.length) {
|
||||
return List.from(candidates);
|
||||
}
|
||||
|
||||
final selected = <Question>[];
|
||||
final available = List<Question>.from(candidates);
|
||||
|
||||
while (selected.length < count && available.isNotEmpty) {
|
||||
final question = _selectOne(
|
||||
candidates: available,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
selected.add(question);
|
||||
available.remove(question);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// Selects a single question using weighted random selection.
|
||||
Question _selectOne({
|
||||
required List<Question> candidates,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (candidates.length == 1) {
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
// Calculate weights for all candidates
|
||||
final weights = candidates.map((q) {
|
||||
if (q.isKnown) {
|
||||
return SpacedRepetition.calculateKnownQuestionWeight(
|
||||
lastAttemptIndex: q.lastAttemptIndex,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
} else {
|
||||
// Unknown questions: priority + 1 to ensure non-zero weight
|
||||
return (q.priorityPoints + 1).toDouble();
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Calculate cumulative weights
|
||||
final cumulativeWeights = <double>[];
|
||||
double sum = 0.0;
|
||||
for (final weight in weights) {
|
||||
sum += weight;
|
||||
cumulativeWeights.add(sum);
|
||||
}
|
||||
|
||||
// Select random value in range [0, sum)
|
||||
final randomValue = _random.nextDouble() * sum;
|
||||
|
||||
// Find the index corresponding to the random value
|
||||
for (int i = 0; i < cumulativeWeights.length; i++) {
|
||||
if (randomValue < cumulativeWeights[i]) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to last item (shouldn't happen, but safety)
|
||||
return candidates.last;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
for (final question in attempt.questions) {
|
||||
final userAnswer = answers[question.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 = question.correctIndices;
|
||||
final userSet = userAnswerIndices.toSet();
|
||||
final correctSet = correctIndices.toSet();
|
||||
final isCorrect = userSet.length == correctSet.length &&
|
||||
userSet.every((idx) => correctSet.contains(idx));
|
||||
final userMarkedNeedsPractice = overrides[question.id] ?? false;
|
||||
|
||||
// Determine status change
|
||||
final oldIsKnown = question.isKnown;
|
||||
final oldStreak = question.consecutiveCorrect;
|
||||
|
||||
// Update question
|
||||
final updated = userMarkedNeedsPractice
|
||||
? DeckService.updateQuestionWithManualOverride(
|
||||
question: question,
|
||||
isCorrect: isCorrect,
|
||||
userMarkedNeedsPractice: true,
|
||||
config: deck.config,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
)
|
||||
: DeckService.updateQuestionAfterAnswer(
|
||||
question: question,
|
||||
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)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import '../models/deck.dart';
|
||||
import '../models/question.dart';
|
||||
import '../models/deck_config.dart';
|
||||
import '../algorithms/priority_manager.dart';
|
||||
|
||||
/// Service for managing deck operations.
|
||||
class DeckService {
|
||||
/// Marks a question as known (manual override).
|
||||
///
|
||||
/// Sets streak to threshold and isKnown to true.
|
||||
static Deck markQuestionAsKnown({
|
||||
required Deck deck,
|
||||
required String questionId,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
if (q.id == questionId) {
|
||||
return q.copyWith(
|
||||
consecutiveCorrect: deck.config.requiredConsecutiveCorrect,
|
||||
isKnown: true,
|
||||
);
|
||||
}
|
||||
return q;
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Marks a question as needs practice (manual override).
|
||||
///
|
||||
/// Sets isKnown to false and streak to 0, but keeps priority unchanged.
|
||||
static Deck markQuestionAsNeedsPractice({
|
||||
required Deck deck,
|
||||
required String questionId,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
if (q.id == questionId) {
|
||||
return q.copyWith(
|
||||
isKnown: false,
|
||||
consecutiveCorrect: 0,
|
||||
// priorityPoints remains unchanged
|
||||
);
|
||||
}
|
||||
return q;
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Resets the entire deck.
|
||||
///
|
||||
/// Resets all questions: streak = 0, isKnown = false, priority = 0.
|
||||
/// Optionally resets totalAttempts if [resetAttemptCounts] is true.
|
||||
/// Optionally clears attempt history if [clearAttemptHistory] is true.
|
||||
static Deck resetDeck({
|
||||
required Deck deck,
|
||||
bool resetAttemptCounts = false,
|
||||
bool clearAttemptHistory = false,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
return q.copyWith(
|
||||
consecutiveCorrect: 0,
|
||||
isKnown: false,
|
||||
priorityPoints: 0,
|
||||
lastAttemptIndex: -1,
|
||||
totalCorrectAttempts: resetAttemptCounts ? 0 : q.totalCorrectAttempts,
|
||||
totalAttempts: resetAttemptCounts ? 0 : q.totalAttempts,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(
|
||||
questions: updatedQuestions,
|
||||
currentAttemptIndex: 0,
|
||||
attemptHistory: clearAttemptHistory ? [] : deck.attemptHistory,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a question's state after an answer.
|
||||
///
|
||||
/// Applies correctness rules and updates streak, priority, and isKnown.
|
||||
static Question updateQuestionAfterAnswer({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required DeckConfig config,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
Question updated = question;
|
||||
|
||||
if (isCorrect) {
|
||||
// Increment streak
|
||||
final newStreak = question.consecutiveCorrect + 1;
|
||||
updated = updated.copyWith(consecutiveCorrect: newStreak);
|
||||
|
||||
// Update isKnown if streak reaches threshold
|
||||
if (newStreak >= config.requiredConsecutiveCorrect) {
|
||||
updated = updated.copyWith(isKnown: true);
|
||||
}
|
||||
|
||||
// Decrease priority
|
||||
updated = PriorityManager.applyAnswerResult(
|
||||
question: updated,
|
||||
isCorrect: true,
|
||||
config: config,
|
||||
);
|
||||
|
||||
// Update totals
|
||||
updated = updated.copyWith(
|
||||
totalCorrectAttempts: question.totalCorrectAttempts + 1,
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
);
|
||||
} else {
|
||||
// Reset streak
|
||||
updated = updated.copyWith(
|
||||
consecutiveCorrect: 0,
|
||||
isKnown: false,
|
||||
);
|
||||
|
||||
// Increase priority
|
||||
updated = PriorityManager.applyAnswerResult(
|
||||
question: updated,
|
||||
isCorrect: false,
|
||||
config: config,
|
||||
);
|
||||
|
||||
// Update totals
|
||||
updated = updated.copyWith(
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// Update lastAttemptIndex
|
||||
updated = updated.copyWith(lastAttemptIndex: currentAttemptIndex);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates a question with manual override after an answer.
|
||||
///
|
||||
/// If user marks as "Needs Practice" even after correct answer,
|
||||
/// ignore correctness and don't increment streak or decrease priority.
|
||||
static Question updateQuestionWithManualOverride({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required bool userMarkedNeedsPractice,
|
||||
required DeckConfig config,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (userMarkedNeedsPractice) {
|
||||
// User marked as needs practice - ignore correctness
|
||||
// Don't increment streak, don't decrease priority
|
||||
// Just update attempt counts and lastAttemptIndex
|
||||
return question.copyWith(
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
lastAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
} else {
|
||||
// Normal flow
|
||||
return updateQuestionAfterAnswer(
|
||||
question: question,
|
||||
isCorrect: isCorrect,
|
||||
config: config,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../models/question.dart';
|
||||
|
||||
/// A single attempt/quiz session.
|
||||
class Attempt {
|
||||
/// Unique identifier for this attempt.
|
||||
final String id;
|
||||
|
||||
/// List of questions included in this attempt.
|
||||
final List<Question> questions;
|
||||
|
||||
/// Timestamp when the attempt was started (milliseconds since epoch).
|
||||
final int startTime;
|
||||
|
||||
const Attempt({
|
||||
required this.id,
|
||||
required this.questions,
|
||||
required this.startTime,
|
||||
});
|
||||
|
||||
/// Creates a copy of this attempt with the given fields replaced.
|
||||
Attempt copyWith({
|
||||
String? id,
|
||||
List<Question>? questions,
|
||||
int? startTime,
|
||||
}) {
|
||||
return Attempt(
|
||||
id: id ?? this.id,
|
||||
questions: questions ?? this.questions,
|
||||
startTime: startTime ?? this.startTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Number of questions in this attempt.
|
||||
int get questionCount => questions.length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'attempt_result.dart';
|
||||
|
||||
/// A historical record of a completed attempt.
|
||||
class AttemptHistoryEntry {
|
||||
/// Timestamp when the attempt was completed (milliseconds since epoch).
|
||||
final int timestamp;
|
||||
|
||||
/// Total number of questions in the attempt.
|
||||
final int totalQuestions;
|
||||
|
||||
/// Number of correct answers.
|
||||
final int correctCount;
|
||||
|
||||
/// Percentage of correct answers.
|
||||
final double percentageCorrect;
|
||||
|
||||
/// Time spent on the attempt in milliseconds.
|
||||
final int timeSpent;
|
||||
|
||||
/// Percentage of questions in the deck that are not yet known (0-100).
|
||||
/// This represents progress - as more questions become known, this decreases.
|
||||
final double unknownPercentage;
|
||||
|
||||
const AttemptHistoryEntry({
|
||||
required this.timestamp,
|
||||
required this.totalQuestions,
|
||||
required this.correctCount,
|
||||
required this.percentageCorrect,
|
||||
required this.timeSpent,
|
||||
this.unknownPercentage = 0.0,
|
||||
});
|
||||
|
||||
/// Creates a history entry from an attempt result.
|
||||
factory AttemptHistoryEntry.fromAttemptResult({
|
||||
required AttemptResult result,
|
||||
required int totalQuestionsInDeck,
|
||||
required int knownCount,
|
||||
int? timestamp,
|
||||
}) {
|
||||
final unknownCount = totalQuestionsInDeck - knownCount;
|
||||
final unknownPercentage = totalQuestionsInDeck > 0
|
||||
? (unknownCount / totalQuestionsInDeck * 100.0)
|
||||
: 0.0;
|
||||
|
||||
return AttemptHistoryEntry(
|
||||
timestamp: timestamp ?? DateTime.now().millisecondsSinceEpoch,
|
||||
totalQuestions: result.totalQuestions,
|
||||
correctCount: result.correctCount,
|
||||
percentageCorrect: result.percentageCorrect,
|
||||
timeSpent: result.timeSpent,
|
||||
unknownPercentage: unknownPercentage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a copy of this entry with the given fields replaced.
|
||||
AttemptHistoryEntry copyWith({
|
||||
int? timestamp,
|
||||
int? totalQuestions,
|
||||
int? correctCount,
|
||||
double? percentageCorrect,
|
||||
int? timeSpent,
|
||||
double? unknownPercentage,
|
||||
}) {
|
||||
return AttemptHistoryEntry(
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
totalQuestions: totalQuestions ?? this.totalQuestions,
|
||||
correctCount: correctCount ?? this.correctCount,
|
||||
percentageCorrect: percentageCorrect ?? this.percentageCorrect,
|
||||
timeSpent: timeSpent ?? this.timeSpent,
|
||||
unknownPercentage: unknownPercentage ?? this.unknownPercentage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AttemptHistoryEntry &&
|
||||
runtimeType == other.runtimeType &&
|
||||
timestamp == other.timestamp &&
|
||||
totalQuestions == other.totalQuestions &&
|
||||
correctCount == other.correctCount &&
|
||||
percentageCorrect == other.percentageCorrect &&
|
||||
timeSpent == other.timeSpent &&
|
||||
unknownPercentage == other.unknownPercentage;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
timestamp.hashCode ^
|
||||
totalQuestions.hashCode ^
|
||||
correctCount.hashCode ^
|
||||
percentageCorrect.hashCode ^
|
||||
timeSpent.hashCode ^
|
||||
unknownPercentage.hashCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'question.dart';
|
||||
|
||||
/// Status change for a question after an attempt.
|
||||
enum QuestionStatusChange {
|
||||
improved,
|
||||
regressed,
|
||||
unchanged,
|
||||
}
|
||||
|
||||
/// Result of a single answer in an attempt.
|
||||
class AnswerResult {
|
||||
/// The question that was answered.
|
||||
final Question question;
|
||||
|
||||
/// The indices of answers the user selected.
|
||||
final List<int> userAnswerIndices;
|
||||
|
||||
/// Deprecated: Use [userAnswerIndices] instead. Returns the first selected answer index.
|
||||
@Deprecated('Use userAnswerIndices instead')
|
||||
int get userAnswerIndex => userAnswerIndices.isNotEmpty ? userAnswerIndices.first : -1;
|
||||
|
||||
/// Whether the answer was correct.
|
||||
final bool isCorrect;
|
||||
|
||||
/// The status change for this question.
|
||||
final QuestionStatusChange statusChange;
|
||||
|
||||
const AnswerResult({
|
||||
required this.question,
|
||||
required this.userAnswerIndices,
|
||||
required this.isCorrect,
|
||||
required this.statusChange,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of a completed attempt.
|
||||
class AttemptResult {
|
||||
/// Total number of questions in the attempt.
|
||||
final int totalQuestions;
|
||||
|
||||
/// Number of correct answers.
|
||||
final int correctCount;
|
||||
|
||||
/// Percentage of correct answers.
|
||||
final double percentageCorrect;
|
||||
|
||||
/// Time spent on the attempt in milliseconds.
|
||||
final int timeSpent;
|
||||
|
||||
/// List of questions that were answered incorrectly.
|
||||
final List<AnswerResult> incorrectQuestions;
|
||||
|
||||
/// List of all answer results.
|
||||
final List<AnswerResult> allResults;
|
||||
|
||||
const AttemptResult({
|
||||
required this.totalQuestions,
|
||||
required this.correctCount,
|
||||
required this.percentageCorrect,
|
||||
required this.timeSpent,
|
||||
required this.incorrectQuestions,
|
||||
required this.allResults,
|
||||
});
|
||||
|
||||
/// Creates an attempt result from answer results.
|
||||
factory AttemptResult.fromAnswers({
|
||||
required List<AnswerResult> results,
|
||||
required int timeSpent,
|
||||
}) {
|
||||
final correctCount = results.where((r) => r.isCorrect).length;
|
||||
final totalQuestions = results.length;
|
||||
final percentageCorrect = totalQuestions > 0
|
||||
? (correctCount / totalQuestions) * 100.0
|
||||
: 0.0;
|
||||
final incorrectQuestions =
|
||||
results.where((r) => !r.isCorrect).toList();
|
||||
|
||||
return AttemptResult(
|
||||
totalQuestions: totalQuestions,
|
||||
correctCount: correctCount,
|
||||
percentageCorrect: percentageCorrect,
|
||||
timeSpent: timeSpent,
|
||||
incorrectQuestions: incorrectQuestions,
|
||||
allResults: results,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'deck_config.dart';
|
||||
import 'question.dart';
|
||||
import 'attempt_history_entry.dart';
|
||||
import 'incomplete_attempt.dart';
|
||||
|
||||
/// A practice deck containing questions and configuration.
|
||||
class Deck {
|
||||
/// Unique identifier for this deck.
|
||||
final String id;
|
||||
|
||||
/// Title of the deck.
|
||||
final String title;
|
||||
|
||||
/// Description of the deck.
|
||||
final String description;
|
||||
|
||||
/// List of questions in this deck.
|
||||
final List<Question> questions;
|
||||
|
||||
/// Configuration for this deck.
|
||||
final DeckConfig config;
|
||||
|
||||
/// Current global attempt index (incremented with each attempt).
|
||||
final int currentAttemptIndex;
|
||||
|
||||
/// History of completed attempts.
|
||||
final List<AttemptHistoryEntry> attemptHistory;
|
||||
|
||||
/// Incomplete attempt that can be resumed.
|
||||
final IncompleteAttempt? incompleteAttempt;
|
||||
|
||||
const Deck({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.questions,
|
||||
required this.config,
|
||||
this.currentAttemptIndex = 0,
|
||||
this.attemptHistory = const [],
|
||||
this.incompleteAttempt,
|
||||
});
|
||||
|
||||
/// Creates a copy of this deck with the given fields replaced.
|
||||
Deck copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
List<Question>? questions,
|
||||
DeckConfig? config,
|
||||
int? currentAttemptIndex,
|
||||
List<AttemptHistoryEntry>? attemptHistory,
|
||||
IncompleteAttempt? incompleteAttempt,
|
||||
bool clearIncompleteAttempt = false,
|
||||
}) {
|
||||
return Deck(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
questions: questions ?? this.questions,
|
||||
config: config ?? this.config,
|
||||
currentAttemptIndex: currentAttemptIndex ?? this.currentAttemptIndex,
|
||||
attemptHistory: attemptHistory ?? this.attemptHistory,
|
||||
incompleteAttempt: clearIncompleteAttempt ? null : (incompleteAttempt ?? this.incompleteAttempt),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Practice percentage: (known / total) * 100
|
||||
double get practicePercentage {
|
||||
if (questions.isEmpty) return 0.0;
|
||||
return (knownCount / questions.length) * 100.0;
|
||||
}
|
||||
|
||||
/// Number of completed attempts.
|
||||
int get attemptCount => attemptHistory.length;
|
||||
|
||||
/// Average percentage correct across all attempts.
|
||||
double get averagePercentageCorrect {
|
||||
if (attemptHistory.isEmpty) return 0.0;
|
||||
final sum = attemptHistory.fold<double>(
|
||||
0.0,
|
||||
(sum, entry) => sum + entry.percentageCorrect,
|
||||
);
|
||||
return sum / attemptHistory.length;
|
||||
}
|
||||
|
||||
/// Total time spent on all attempts in milliseconds.
|
||||
int get totalTimeSpent {
|
||||
return attemptHistory.fold<int>(
|
||||
0,
|
||||
(sum, entry) => sum + entry.timeSpent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Deck &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
title == other.title &&
|
||||
description == other.description &&
|
||||
questions.toString() == other.questions.toString() &&
|
||||
config == other.config &&
|
||||
currentAttemptIndex == other.currentAttemptIndex &&
|
||||
attemptHistory.toString() == other.attemptHistory.toString() &&
|
||||
incompleteAttempt == other.incompleteAttempt;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
title.hashCode ^
|
||||
description.hashCode ^
|
||||
questions.hashCode ^
|
||||
config.hashCode ^
|
||||
currentAttemptIndex.hashCode ^
|
||||
attemptHistory.hashCode ^
|
||||
(incompleteAttempt?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/// Configuration for a practice deck.
|
||||
class DeckConfig {
|
||||
/// Number of consecutive correct answers required to mark a question as known.
|
||||
final int requiredConsecutiveCorrect;
|
||||
|
||||
/// Default number of questions to include in an attempt.
|
||||
final int defaultAttemptSize;
|
||||
|
||||
/// Priority points to add when a question is answered incorrectly.
|
||||
final int priorityIncreaseOnIncorrect;
|
||||
|
||||
/// Priority points to subtract when a question is answered correctly.
|
||||
final int priorityDecreaseOnCorrect;
|
||||
|
||||
/// Whether to provide immediate feedback after each answer.
|
||||
final bool immediateFeedbackEnabled;
|
||||
|
||||
/// Whether to include known questions in attempts.
|
||||
/// If false, known questions will be excluded from attempts.
|
||||
final bool includeKnownInAttempts;
|
||||
|
||||
/// Optional time limit for attempts in seconds.
|
||||
/// If null, no time limit is enforced.
|
||||
final int? timeLimitSeconds;
|
||||
|
||||
const DeckConfig({
|
||||
this.requiredConsecutiveCorrect = 3,
|
||||
this.defaultAttemptSize = 10,
|
||||
this.priorityIncreaseOnIncorrect = 5,
|
||||
this.priorityDecreaseOnCorrect = 2,
|
||||
this.immediateFeedbackEnabled = true,
|
||||
this.includeKnownInAttempts = false,
|
||||
this.timeLimitSeconds,
|
||||
});
|
||||
|
||||
/// Creates a copy of this config with the given fields replaced.
|
||||
DeckConfig copyWith({
|
||||
int? requiredConsecutiveCorrect,
|
||||
int? defaultAttemptSize,
|
||||
int? priorityIncreaseOnIncorrect,
|
||||
int? priorityDecreaseOnCorrect,
|
||||
bool? immediateFeedbackEnabled,
|
||||
bool? includeKnownInAttempts,
|
||||
int? timeLimitSeconds,
|
||||
}) {
|
||||
return DeckConfig(
|
||||
requiredConsecutiveCorrect:
|
||||
requiredConsecutiveCorrect ?? this.requiredConsecutiveCorrect,
|
||||
defaultAttemptSize: defaultAttemptSize ?? this.defaultAttemptSize,
|
||||
priorityIncreaseOnIncorrect:
|
||||
priorityIncreaseOnIncorrect ?? this.priorityIncreaseOnIncorrect,
|
||||
priorityDecreaseOnCorrect:
|
||||
priorityDecreaseOnCorrect ?? this.priorityDecreaseOnCorrect,
|
||||
immediateFeedbackEnabled:
|
||||
immediateFeedbackEnabled ?? this.immediateFeedbackEnabled,
|
||||
includeKnownInAttempts:
|
||||
includeKnownInAttempts ?? this.includeKnownInAttempts,
|
||||
timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DeckConfig &&
|
||||
runtimeType == other.runtimeType &&
|
||||
requiredConsecutiveCorrect == other.requiredConsecutiveCorrect &&
|
||||
defaultAttemptSize == other.defaultAttemptSize &&
|
||||
priorityIncreaseOnIncorrect == other.priorityIncreaseOnIncorrect &&
|
||||
priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect &&
|
||||
immediateFeedbackEnabled == other.immediateFeedbackEnabled &&
|
||||
includeKnownInAttempts == other.includeKnownInAttempts &&
|
||||
timeLimitSeconds == other.timeLimitSeconds;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
requiredConsecutiveCorrect.hashCode ^
|
||||
defaultAttemptSize.hashCode ^
|
||||
priorityIncreaseOnIncorrect.hashCode ^
|
||||
priorityDecreaseOnCorrect.hashCode ^
|
||||
immediateFeedbackEnabled.hashCode ^
|
||||
includeKnownInAttempts.hashCode ^
|
||||
(timeLimitSeconds?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'attempt.dart';
|
||||
import 'question.dart';
|
||||
|
||||
/// Represents an incomplete attempt that can be resumed later.
|
||||
class IncompleteAttempt {
|
||||
/// The attempt ID.
|
||||
final String attemptId;
|
||||
|
||||
/// List of question IDs in the attempt (in order).
|
||||
final List<String> questionIds;
|
||||
|
||||
/// Timestamp when the attempt was started.
|
||||
final int startTime;
|
||||
|
||||
/// Current question index (0-based).
|
||||
final int currentQuestionIndex;
|
||||
|
||||
/// Map of questionId -> answer (int for single, List<int> for multiple).
|
||||
final Map<String, dynamic> answers;
|
||||
|
||||
/// Map of questionId -> manual override (needs practice).
|
||||
final Map<String, bool> manualOverrides;
|
||||
|
||||
/// Timestamp when the attempt was paused.
|
||||
final int pausedAt;
|
||||
|
||||
/// Remaining time in seconds (if time limit was set).
|
||||
final int? remainingSeconds;
|
||||
|
||||
const IncompleteAttempt({
|
||||
required this.attemptId,
|
||||
required this.questionIds,
|
||||
required this.startTime,
|
||||
required this.currentQuestionIndex,
|
||||
required this.answers,
|
||||
required this.manualOverrides,
|
||||
required this.pausedAt,
|
||||
this.remainingSeconds,
|
||||
});
|
||||
|
||||
/// Creates an Attempt from this incomplete attempt using questions from the deck.
|
||||
Attempt toAttempt(List<Question> deckQuestions) {
|
||||
final questionMap = {for (var q in deckQuestions) q.id: q};
|
||||
final questions = questionIds
|
||||
.map((id) => questionMap[id])
|
||||
.whereType<Question>()
|
||||
.toList();
|
||||
|
||||
return Attempt(
|
||||
id: attemptId,
|
||||
questions: questions,
|
||||
startTime: startTime,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an incomplete attempt from JSON.
|
||||
factory IncompleteAttempt.fromJson(Map<String, dynamic> json) {
|
||||
return IncompleteAttempt(
|
||||
attemptId: json['attemptId'] as String,
|
||||
questionIds: List<String>.from(json['questionIds'] as List),
|
||||
startTime: json['startTime'] as int,
|
||||
currentQuestionIndex: json['currentQuestionIndex'] as int,
|
||||
answers: Map<String, dynamic>.from(json['answers'] as Map),
|
||||
manualOverrides: Map<String, bool>.from(
|
||||
(json['manualOverrides'] as Map?)?.map((k, v) => MapEntry(k.toString(), v as bool)) ?? {},
|
||||
),
|
||||
pausedAt: json['pausedAt'] as int,
|
||||
remainingSeconds: json['remainingSeconds'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts to JSON for storage.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'attemptId': attemptId,
|
||||
'questionIds': questionIds,
|
||||
'startTime': startTime,
|
||||
'currentQuestionIndex': currentQuestionIndex,
|
||||
'answers': answers,
|
||||
'manualOverrides': manualOverrides,
|
||||
'pausedAt': pausedAt,
|
||||
if (remainingSeconds != null) 'remainingSeconds': remainingSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a copy with updated fields.
|
||||
IncompleteAttempt copyWith({
|
||||
String? attemptId,
|
||||
List<String>? questionIds,
|
||||
int? startTime,
|
||||
int? currentQuestionIndex,
|
||||
Map<String, dynamic>? answers,
|
||||
Map<String, bool>? manualOverrides,
|
||||
int? pausedAt,
|
||||
int? remainingSeconds,
|
||||
}) {
|
||||
return IncompleteAttempt(
|
||||
attemptId: attemptId ?? this.attemptId,
|
||||
questionIds: questionIds ?? this.questionIds,
|
||||
startTime: startTime ?? this.startTime,
|
||||
currentQuestionIndex: currentQuestionIndex ?? this.currentQuestionIndex,
|
||||
answers: answers ?? this.answers,
|
||||
manualOverrides: manualOverrides ?? this.manualOverrides,
|
||||
pausedAt: pausedAt ?? this.pausedAt,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is IncompleteAttempt &&
|
||||
runtimeType == other.runtimeType &&
|
||||
attemptId == other.attemptId &&
|
||||
questionIds.toString() == other.questionIds.toString() &&
|
||||
startTime == other.startTime &&
|
||||
currentQuestionIndex == other.currentQuestionIndex &&
|
||||
answers.toString() == other.answers.toString() &&
|
||||
manualOverrides.toString() == other.manualOverrides.toString() &&
|
||||
pausedAt == other.pausedAt &&
|
||||
remainingSeconds == other.remainingSeconds;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
attemptId.hashCode ^
|
||||
questionIds.hashCode ^
|
||||
startTime.hashCode ^
|
||||
currentQuestionIndex.hashCode ^
|
||||
answers.hashCode ^
|
||||
manualOverrides.hashCode ^
|
||||
pausedAt.hashCode ^
|
||||
(remainingSeconds?.hashCode ?? 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/// A question in a practice deck.
|
||||
class Question {
|
||||
/// Unique identifier for this question.
|
||||
final String id;
|
||||
|
||||
/// The question prompt.
|
||||
final String prompt;
|
||||
|
||||
/// List of possible answers.
|
||||
final List<String> answers;
|
||||
|
||||
/// Indices of correct answers in [answers].
|
||||
/// For backward compatibility, if empty, falls back to [correctAnswerIndex] (deprecated).
|
||||
final List<int> correctAnswerIndices;
|
||||
|
||||
/// Deprecated: Use [correctAnswerIndices] instead.
|
||||
/// Kept for backward compatibility with existing data.
|
||||
@Deprecated('Use correctAnswerIndices instead')
|
||||
final int? correctAnswerIndex;
|
||||
|
||||
/// Number of consecutive correct answers.
|
||||
final int consecutiveCorrect;
|
||||
|
||||
/// Whether this question is considered "known".
|
||||
final bool isKnown;
|
||||
|
||||
/// Priority points (higher = more likely to be selected).
|
||||
final int priorityPoints;
|
||||
|
||||
/// The last attempt index where this question was seen.
|
||||
final int lastAttemptIndex;
|
||||
|
||||
/// Total number of correct attempts.
|
||||
final int totalCorrectAttempts;
|
||||
|
||||
/// Total number of attempts.
|
||||
final int totalAttempts;
|
||||
|
||||
Question({
|
||||
required this.id,
|
||||
required this.prompt,
|
||||
required this.answers,
|
||||
List<int>? correctAnswerIndices,
|
||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||
this.consecutiveCorrect = 0,
|
||||
this.isKnown = false,
|
||||
this.priorityPoints = 0,
|
||||
this.lastAttemptIndex = -1,
|
||||
this.totalCorrectAttempts = 0,
|
||||
this.totalAttempts = 0,
|
||||
}) : correctAnswerIndices = correctAnswerIndices ??
|
||||
(correctAnswerIndex != null ? [correctAnswerIndex] : const []),
|
||||
correctAnswerIndex = correctAnswerIndex;
|
||||
|
||||
/// Creates a copy of this question with the given fields replaced.
|
||||
Question copyWith({
|
||||
String? id,
|
||||
String? prompt,
|
||||
List<String>? answers,
|
||||
List<int>? correctAnswerIndices,
|
||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||
int? consecutiveCorrect,
|
||||
bool? isKnown,
|
||||
int? priorityPoints,
|
||||
int? lastAttemptIndex,
|
||||
int? totalCorrectAttempts,
|
||||
int? totalAttempts,
|
||||
}) {
|
||||
return Question(
|
||||
id: id ?? this.id,
|
||||
prompt: prompt ?? this.prompt,
|
||||
answers: answers ?? this.answers,
|
||||
correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices,
|
||||
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
|
||||
consecutiveCorrect: consecutiveCorrect ?? this.consecutiveCorrect,
|
||||
isKnown: isKnown ?? this.isKnown,
|
||||
priorityPoints: priorityPoints ?? this.priorityPoints,
|
||||
lastAttemptIndex: lastAttemptIndex ?? this.lastAttemptIndex,
|
||||
totalCorrectAttempts: totalCorrectAttempts ?? this.totalCorrectAttempts,
|
||||
totalAttempts: totalAttempts ?? this.totalAttempts,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the correct answer indices, with backward compatibility.
|
||||
List<int> get correctIndices {
|
||||
if (correctAnswerIndices.isNotEmpty) {
|
||||
return correctAnswerIndices;
|
||||
}
|
||||
// Backward compatibility
|
||||
if (correctAnswerIndex != null) {
|
||||
return [correctAnswerIndex!];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Checks if an answer index is correct.
|
||||
bool isCorrectAnswer(int index) {
|
||||
return correctIndices.contains(index);
|
||||
}
|
||||
|
||||
/// Whether this question has multiple correct answers.
|
||||
bool get hasMultipleCorrectAnswers => correctIndices.length > 1;
|
||||
|
||||
/// Validates that priorityPoints is non-negative.
|
||||
Question withPriorityPoints(int points) {
|
||||
return copyWith(priorityPoints: points < 0 ? 0 : points);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Question &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
prompt == other.prompt &&
|
||||
answers.toString() == other.answers.toString() &&
|
||||
correctAnswerIndices.toString() == other.correctAnswerIndices.toString() &&
|
||||
consecutiveCorrect == other.consecutiveCorrect &&
|
||||
isKnown == other.isKnown &&
|
||||
priorityPoints == other.priorityPoints &&
|
||||
lastAttemptIndex == other.lastAttemptIndex &&
|
||||
totalCorrectAttempts == other.totalCorrectAttempts &&
|
||||
totalAttempts == other.totalAttempts;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
prompt.hashCode ^
|
||||
answers.hashCode ^
|
||||
correctAnswerIndices.hashCode ^
|
||||
consecutiveCorrect.hashCode ^
|
||||
isKnown.hashCode ^
|
||||
priorityPoints.hashCode ^
|
||||
lastAttemptIndex.hashCode ^
|
||||
totalCorrectAttempts.hashCode ^
|
||||
totalAttempts.hashCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Practice Engine - A pure Dart package for spaced repetition and practice tracking.
|
||||
library practice_engine;
|
||||
|
||||
// Models
|
||||
export 'models/deck.dart';
|
||||
export 'models/deck_config.dart';
|
||||
export 'models/question.dart';
|
||||
export 'models/attempt.dart';
|
||||
export 'models/attempt_result.dart';
|
||||
export 'models/attempt_history_entry.dart';
|
||||
export 'models/incomplete_attempt.dart';
|
||||
|
||||
// Services
|
||||
export 'logic/deck_service.dart';
|
||||
export 'logic/attempt_service.dart';
|
||||
|
||||
// Algorithms
|
||||
export 'algorithms/weighted_selector.dart';
|
||||
export 'algorithms/spaced_repetition.dart';
|
||||
export 'algorithms/priority_manager.dart';
|
||||
|
||||
Reference in New Issue
Block a user