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
@@ -44,11 +44,7 @@ class _LessonFlowState extends State<LessonFlow> {
final independentController = TextEditingController();
int selectedAnswer = -1;
/// Set once a wrong meaning is picked, so only a first-try choice counts as
/// recognition evidence.
bool listeningMissed = false;
int previewIndex = 0;
bool listeningAudioPlayed = false;
bool showWritingHelp = true;
bool showIndependentHelp = false;
LessonSegment get segment {
@@ -56,11 +52,9 @@ class _LessonFlowState extends State<LessonFlow> {
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
}
LessonActivity get activity =>
activityBySegmentId(segment.id);
LessonActivity get activity => activityBySegmentId(segment.id);
List<VocabularyItem> get previewItems =>
vocabularyBySegmentId(segment.id);
List<VocabularyItem> get previewItems => vocabularyBySegmentId(segment.id);
@override
void initState() {
@@ -122,17 +116,21 @@ class _LessonFlowState extends State<LessonFlow> {
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
missed: listeningMissed,
onSelected: (value) => setState(() {
selectedAnswer = value;
audioPlayed: widget.state.listeningAudioPlayed,
audioUnavailable: widget.state.listeningAudioUnavailable,
support: widget.state.listeningSupport,
missed: widget.state.listeningAttempts > 0,
onSelected: (value) {
setState(() => selectedAnswer = value);
if (listeningOptions[value] != activity.answers.first) {
listeningMissed = true;
widget.state.recordListeningMiss();
}
}),
onPlayed: () => setState(() => listeningAudioPlayed = true),
},
onPlayed: () => widget.state.recordListeningPlayback(),
onAudioFailed: widget.state.recordListeningAudioUnavailable,
onSlowPlayed: () => widget.state.recordListeningPlayback(slow: true),
onLookup: () {
setState(() => listeningMissed = true);
widget.state.revealListeningTranscript();
showLexiconLookup(
context,
state: widget.state,
@@ -142,8 +140,7 @@ class _LessonFlowState extends State<LessonFlow> {
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? () =>
widget.state.completeListening(recognized: !listeningMissed)
? () => widget.state.completeListening(recognized: true)
: null,
);
case LessonStep.speaking:
@@ -152,8 +149,13 @@ class _LessonFlowState extends State<LessonFlow> {
segmentId: segment.id,
text: activity.speaking,
tip: activity.speakingTip,
chunks: activity.speakingChunks,
examples: activity.speakingExamples,
buildIndex: widget.state.sentenceBuildIndex,
onBuildProgress: widget.state.recordSentenceBuildProgress,
keepRecording: widget.state.keepRecordings,
onContinue: widget.state.completeSpeaking,
onContinue: (practiced) =>
widget.state.completeSpeaking(practiced: practiced),
);
case LessonStep.reading:
content = _ReadingStep(
@@ -191,8 +193,11 @@ class _LessonFlowState extends State<LessonFlow> {
},
onToggleHelp: () =>
setState(() => showWritingHelp = !showWritingHelp),
onContinue: (usedAiFeedback) => widget.state.completeWriting(
assisted: showWritingHelp || usedAiFeedback,
onContinue: (verified) => widget.state.completeWriting(
// The example is visible when this guided step opens. Hiding it
// later cannot turn the same answer into an independent attempt.
assisted: true,
verified: verified,
rawAnswer: writingController.text.trim(),
),
);
@@ -221,9 +226,10 @@ class _LessonFlowState extends State<LessonFlow> {
initialText: activity.independentPrompt,
);
},
onContinue: (spoken, recordingPath, aiCorrected) =>
onContinue: (spoken, recordingPath, aiCorrected, verified) =>
widget.state.completeIndependentAttempt(
assisted: showIndependentHelp || aiCorrected,
verified: verified,
spoken: spoken,
rawAnswer: independentController.text.trim(),
recordingPath: recordingPath,
@@ -234,6 +240,7 @@ class _LessonFlowState extends State<LessonFlow> {
case LessonStep.complete:
content = _CompletionStep(
assisted: widget.state.independentAttemptAssisted,
pending: widget.state.independentAttemptPending,
isFinalSegment:
widget.state.activeSegmentIndexFor(lesson.id) ==
lesson.segments.length - 1,
@@ -343,11 +350,13 @@ class _DialoguePendingStep extends StatelessWidget {
class _CompletionStep extends StatelessWidget {
const _CompletionStep({
required this.assisted,
required this.pending,
required this.isFinalSegment,
required this.nextSegmentNumber,
required this.onFinish,
});
final bool assisted;
final bool pending;
final bool isFinalSegment;
final int nextSegmentNumber;
final VoidCallback onFinish;
@@ -362,7 +371,13 @@ class _CompletionStep extends StatelessWidget {
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
Text(
pending
? '练习已保存,但答案未经 AI 核验,不计入独立掌握。后续会安排复练。'
: assisted
? '独立尝试使用了帮助,系统会安排不同题再练一次。'
: '这次独立尝试已记录,后续会在不同情境中复练。',
),
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
PrimaryButton(label: '回到首页', onPressed: onFinish),
],
@@ -371,20 +386,28 @@ class _CompletionStep extends StatelessWidget {
}
class _AudioRow extends StatelessWidget {
const _AudioRow({required this.label, this.speech, this.onPlayed});
const _AudioRow({
required this.label,
this.speech,
this.onPlayed,
this.onFailed,
this.showSlow = true,
});
final String label;
final String? speech;
final VoidCallback? onPlayed;
final VoidCallback? onFailed;
final bool showSlow;
@override
Widget build(BuildContext context) => Row(
children: [
IconButton.filled(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
if (await VoiceService.instance.speak(speech ?? label)) {
onPlayed?.call();
} else {
onFailed?.call();
}
},
icon: const Icon(Icons.play_arrow),
@@ -393,10 +416,10 @@ class _AudioRow extends StatelessWidget {
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
if (await VoiceService.instance.speak(speech ?? label)) {
onPlayed?.call();
} else {
onFailed?.call();
}
},
child: Padding(
@@ -405,16 +428,18 @@ class _AudioRow extends StatelessWidget {
),
),
),
TextButton(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
if (showSlow)
TextButton(
onPressed: () async {
if (await VoiceService.instance.speak(
speech ?? label,
slow: true,
)) {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
);
}