feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制

This commit is contained in:
shen
2026-09-23 18:33:22 -07:00
parent 6b42b7abc3
commit d740a9e19c
106 changed files with 2933 additions and 845 deletions
@@ -22,7 +22,9 @@ class AssessmentTask {
skill: AssessmentSkill.values.byName(json['skill'] as String),
prompt: json['prompt'] as String? ?? '',
audio: json['audio'] as String?,
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
choices: [
for (final choice in json['choices'] as List? ?? const []) '$choice',
],
answerIndex: json['answerIndex'] as int?,
accept: [
for (final group in json['accept'] as List? ?? const [])
@@ -102,10 +104,7 @@ AssessmentPack? replacementFor(String packId) =>
/// The pack whose results [packId] counts toward: the original of a
/// replacement pack, otherwise [packId] itself.
String canonicalAssessmentPackId(String packId) =>
assessmentPacks
.where((pack) => pack.id == packId)
.firstOrNull
?.replaces ??
assessmentPacks.where((pack) => pack.id == packId).firstOrNull?.replaces ??
packId;
/// Local check of an open task with the same rule engine as the lessons: it
+5 -11
View File
@@ -63,13 +63,7 @@ void registerSavedWord(
required String zh,
required String level,
}) {
receptiveWordRegistry[id] = (
level: level,
unit: '',
en: en,
zh: zh,
ipa: '',
);
receptiveWordRegistry[id] = (level: level, unit: '', en: en, zh: zh, ipa: '');
}
/// Registers one word of the standalone word bank. Unlike a unit's receptive
@@ -191,9 +185,10 @@ WordQuestion? receptiveQuestion(
if (wrong.isEmpty) return null;
// A different variant asks the same word against other meanings, which is
// what 「换题、换语境」 means for a word this small.
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
.take(2)
.toList();
final rotated = [
...wrong.skip(variant % wrong.length),
...wrong.take(variant % wrong.length),
].take(2).toList();
// Asked from the meaning, the options are spellings and the prompt is the
// Chinese; asked either other way, it is the other way round.
final asksSpelling = mode == WordAskMode.recall;
@@ -339,7 +334,6 @@ const spokenRecallSkill = '口头回忆';
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
const recognitionGateSkill = '听辨识别';
/// The skill of a contrast question: a situation is described and the learner
/// picks which taught sentence it calls for. It shows the learner knows *when*
/// to say something, which is not the same as being able to produce it, so it
@@ -12,7 +12,6 @@ class LessonSegment {
this.estimatedMinutes = 12,
});
final String id;
final List<String> targetItemIds;
final List<String> previewItemIds;
@@ -86,8 +85,12 @@ class LessonActivity {
required this.listening,
required this.listeningQuestion,
required this.answers,
this.listeningItemIds = const [],
required this.speaking,
this.speakingTip = '',
this.speakingChunks = const [],
this.speakingItemIds = const [],
this.speakingExamples = const [],
required this.reading,
required this.readingQuestion,
required this.readingAnswer,
@@ -100,14 +103,17 @@ class LessonActivity {
required this.independentHelp,
});
final String listening;
final String listeningQuestion;
final List<String> answers;
final List<String> listeningItemIds;
final String speaking;
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
final String speakingTip;
final List<String> speakingChunks;
final List<String> speakingItemIds;
final List<String> speakingExamples;
final String reading;
final String readingQuestion;
final String readingAnswer;
@@ -135,7 +141,6 @@ class LessonDialogue {
this.taskLabels = const [],
});
final String goal;
final List<String> prompts;
final List<String> hints;
@@ -165,7 +170,6 @@ class DialogueScene {
this.unlockAfterLessonId,
});
final String id;
final String title;
final String summary;
@@ -214,8 +214,12 @@ class PackSegment {
required this.listeningText,
required this.listeningQuestion,
required this.listeningOptions,
this.listeningItemIds = const [],
required this.speakingText,
required this.speakingTip,
this.speakingChunks = const [],
this.speakingItemIds = const [],
this.speakingExamples = const [],
required this.readingText,
required this.readingQuestion,
required this.readingOptions,
@@ -241,9 +245,13 @@ class PackSegment {
final String listeningText;
final String listeningQuestion;
final List<String> listeningOptions;
final List<String> listeningItemIds;
final String speakingText;
final String speakingTip;
final List<String> speakingChunks;
final List<String> speakingItemIds;
final List<String> speakingExamples;
final String readingText;
final String readingQuestion;
@@ -288,8 +296,15 @@ class PackSegment {
listeningText: listening['text'] as String? ?? '',
listeningQuestion: listening['question'] as String? ?? '',
listeningOptions: _stringList(listening['options']),
listeningItemIds: _stringList(listening['itemIds']),
speakingText: firstSpeaking['text'] as String? ?? '',
speakingTip: firstSpeaking['tip'] as String? ?? '',
speakingChunks: _stringList(firstSpeaking['chunks']),
speakingItemIds: _stringList(firstSpeaking['itemIds']),
speakingExamples: [
for (final line in speaking.skip(1))
if (line['text'] is String) line['text'] as String,
],
readingText: reading['text'] as String? ?? '',
readingQuestion: reading['question'] as String? ?? '',
readingOptions: _stringList(reading['options']),
@@ -225,8 +225,12 @@ class CourseRepository {
answers: seg.listeningOptions.isNotEmpty
? seg.listeningOptions
: const [''],
listeningItemIds: seg.listeningItemIds,
speaking: seg.speakingText,
speakingTip: seg.speakingTip,
speakingChunks: seg.speakingChunks,
speakingItemIds: seg.speakingItemIds,
speakingExamples: seg.speakingExamples,
reading: seg.readingText,
readingQuestion: seg.readingQuestion,
readingAnswer: seg.readingOptions.isNotEmpty