merge with practice_engine

This commit is contained in:
gitea
2026-01-02 20:18:48 +01:00
parent e3ffc4d556
commit cad489b678
37 changed files with 3172 additions and 1 deletions
@@ -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;
}