feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// 课程路径:全部课程按顺序排列,显示完成、当前与锁定状态。
|
||||
@@ -17,7 +18,10 @@ class LessonPath extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = allLessons
|
||||
final available = allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.toList();
|
||||
final done = available
|
||||
.where((lesson) => state.completedLessonIds.contains(lesson.id))
|
||||
.length;
|
||||
return SpacedColumn(
|
||||
@@ -32,7 +36,7 @@ class LessonPath extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$done/${allLessons.length} 完成',
|
||||
'$done/${available.length} 已开放课程完成',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -66,7 +70,8 @@ class _LessonTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final completed = state.completedLessonIds.contains(lesson.id);
|
||||
final current = lesson.id == state.activeLessonId && !completed;
|
||||
final draft = lesson.status != ContentStatus.approved;
|
||||
final current = lesson.id == state.activeLessonId && !completed && !draft;
|
||||
final open =
|
||||
state.isLessonUnlocked(lesson.id) &&
|
||||
(!state.reviewBacklog || completed);
|
||||
@@ -98,7 +103,9 @@ class _LessonTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
segments > 1
|
||||
draft
|
||||
? '待教学审校后开放'
|
||||
: segments > 1
|
||||
? '${lesson.outcome} · 小段 $segmentsDone/$segments'
|
||||
: lesson.outcome,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// 今天最该做的一件事:继续课程对话 → 到期复习 → 阶段评估/巩固 → 下一课。
|
||||
@@ -50,7 +51,8 @@ class TodayTaskCard extends StatelessWidget {
|
||||
|
||||
_TodayTask _todayTask() {
|
||||
var lessonId = state.activeLessonId;
|
||||
if (state.completedLessonIds.contains(lessonId)) {
|
||||
if (state.completedLessonIds.contains(lessonId) ||
|
||||
lessonById(lessonId).status != ContentStatus.approved) {
|
||||
final next = state.nextIncompleteLessonId;
|
||||
if (next != null) {
|
||||
lessonId = next;
|
||||
@@ -101,8 +103,8 @@ class TodayTaskCard extends StatelessWidget {
|
||||
if (state.allLessonsComplete) {
|
||||
return _TodayTask(
|
||||
label: '已学完',
|
||||
title: '所有课程已完成',
|
||||
note: '你已学完全部课程内容!建议保持日常到期复习或自由情境对话。',
|
||||
title: '当前开放课程已完成',
|
||||
note: '后续课程待教学审校开放。现在可以继续到期复习或自由情境对话。',
|
||||
action: '去复习',
|
||||
minutes: '10 分钟',
|
||||
onPressed: onStartReview,
|
||||
|
||||
@@ -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('慢速'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ class _IndependentStep extends StatefulWidget {
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onNeedHelp;
|
||||
final VoidCallback onLookup;
|
||||
final void Function(bool spoken, String? recordingPath, bool aiCorrected)
|
||||
final void Function(
|
||||
bool spoken,
|
||||
String? recordingPath,
|
||||
bool aiCorrected,
|
||||
bool verified,
|
||||
)
|
||||
onContinue;
|
||||
final VoidCallback onLater;
|
||||
|
||||
@@ -57,7 +62,8 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Future<void> _submit() async {
|
||||
if (!widget.canContinue || checkingWithAi) return;
|
||||
final shortfall = independentShortfall(
|
||||
widget.segmentId,
|
||||
widget.controller.text,
|
||||
@@ -66,12 +72,20 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
setState(() => validationError = shortfall);
|
||||
return;
|
||||
}
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
_finish(verified: false);
|
||||
return;
|
||||
}
|
||||
await _checkWithAi();
|
||||
if (!mounted) return;
|
||||
if (aiCheck?.verdict == 'accepted') _finish(verified: true);
|
||||
}
|
||||
|
||||
void _finish({required bool verified}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
aiCheck?.verdict == 'accepted'
|
||||
? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。'
|
||||
: '通过:用上了本段要练的表达。',
|
||||
verified ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '练习已保存;答案未经核验,不计入独立掌握。',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -79,6 +93,7 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
usedVoice && !transcriptEdited,
|
||||
widget.keepRecording ? recordingPath : null,
|
||||
aiCorrected,
|
||||
verified,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,13 +105,9 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
|
||||
Future<void> _checkWithAi() async {
|
||||
final answer = widget.controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
@@ -119,9 +130,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
||||
? '暂时无法核验答案。可重试,或仅保存练习继续。'
|
||||
: feedback.verdict == 'uncertain'
|
||||
? 'AI 无法确定答案是否正确。可重试,或仅保存练习继续。'
|
||||
: null;
|
||||
if (feedback != null && feedback.verdict != 'accepted') {
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
aiCorrected = true;
|
||||
}
|
||||
});
|
||||
@@ -191,6 +204,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||||
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(widget.activity.independentPrompt),
|
||||
if (widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
@@ -255,19 +273,6 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.canContinue && !checkingWithAi
|
||||
? _checkWithAi
|
||||
: null,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : '提交前 AI 检查语法和拼写(可选)'),
|
||||
),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
@@ -282,8 +287,10 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
||||
: '看过改正后再提交,本次会记为带提示完成。',
|
||||
? '本地目标与 AI 检查均通过。'
|
||||
: aiCheck!.verdict == 'rewrite'
|
||||
? '修改后再次提交;看过改正,本次会记为辅助练习。'
|
||||
: '答案尚未核验,可重试或仅保存练习。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -322,9 +329,14 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
else
|
||||
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||||
PrimaryButton(
|
||||
label: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成',
|
||||
onPressed: widget.canContinue ? _submit : null,
|
||||
label: checkingWithAi ? '正在自动检查…' : '提交独立尝试',
|
||||
onPressed: widget.canContinue && !checkingWithAi ? _submit : null,
|
||||
),
|
||||
if (aiCheckError != null)
|
||||
TextButton(
|
||||
onPressed: () => _finish(verified: false),
|
||||
child: const Text('未核验,保存练习并继续'),
|
||||
),
|
||||
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,9 +8,13 @@ class _ListeningStep extends StatelessWidget {
|
||||
required this.correctAnswer,
|
||||
required this.selectedAnswer,
|
||||
required this.audioPlayed,
|
||||
required this.audioUnavailable,
|
||||
required this.support,
|
||||
required this.missed,
|
||||
required this.onSelected,
|
||||
required this.onPlayed,
|
||||
required this.onAudioFailed,
|
||||
required this.onSlowPlayed,
|
||||
required this.onLookup,
|
||||
required this.onContinue,
|
||||
});
|
||||
@@ -22,11 +26,15 @@ class _ListeningStep extends StatelessWidget {
|
||||
|
||||
final int selectedAnswer;
|
||||
final bool audioPlayed;
|
||||
final bool audioUnavailable;
|
||||
final ListeningSupport support;
|
||||
|
||||
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
|
||||
final bool missed;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onPlayed;
|
||||
final VoidCallback onAudioFailed;
|
||||
final VoidCallback onSlowPlayed;
|
||||
final VoidCallback onLookup;
|
||||
final VoidCallback? onContinue;
|
||||
|
||||
@@ -35,7 +43,8 @@ class _ListeningStep extends StatelessWidget {
|
||||
final answers = options;
|
||||
final answeredCorrectly =
|
||||
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
|
||||
final revealText = answeredCorrectly || missed;
|
||||
final revealText =
|
||||
answeredCorrectly || support == ListeningSupport.transcript;
|
||||
return _LessonScaffold(
|
||||
step: 2,
|
||||
child: SpacedColumn(
|
||||
@@ -52,13 +61,32 @@ class _ListeningStep extends StatelessWidget {
|
||||
label: audioPlayed ? '再播放一次' : '播放问题',
|
||||
speech: activity.listening,
|
||||
onPlayed: onPlayed,
|
||||
onFailed: onAudioFailed,
|
||||
showSlow: false,
|
||||
),
|
||||
),
|
||||
if (!audioPlayed)
|
||||
if (audioPlayed && !revealText)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final played = await VoiceService.instance.speak(
|
||||
activity.listening,
|
||||
slow: true,
|
||||
);
|
||||
if (played) onSlowPlayed();
|
||||
},
|
||||
icon: const Icon(Icons.slow_motion_video),
|
||||
label: Text(support == ListeningSupport.slow ? '再慢听一次' : '慢一点再听'),
|
||||
),
|
||||
if (!audioPlayed && !audioUnavailable)
|
||||
Text(
|
||||
'先播放音频,听完再选择。原文会在作答后显示。',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
if (audioUnavailable)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('当前设备无法播放语音。下面显示原文,你仍可完成理解练习;本题不会计为独立听辨。'),
|
||||
),
|
||||
if (revealText)
|
||||
SectionCard(child: LexiconText(activity.listening, state: state))
|
||||
else if (audioPlayed)
|
||||
@@ -70,7 +98,9 @@ class _ListeningStep extends StatelessWidget {
|
||||
for (var index = 0; index < answers.length; index++)
|
||||
SectionCard(
|
||||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||
onTap: audioPlayed ? () => onSelected(index) : null,
|
||||
onTap: audioPlayed || audioUnavailable
|
||||
? () => onSelected(index)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -84,7 +114,7 @@ class _ListeningStep extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
answers[index],
|
||||
style: audioPlayed
|
||||
style: audioPlayed || audioUnavailable
|
||||
? null
|
||||
: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
@@ -94,14 +124,11 @@ class _ListeningStep extends StatelessWidget {
|
||||
PrimaryButton(
|
||||
label: selectedAnswer >= 0
|
||||
? '检查并继续'
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频'),
|
||||
: (audioPlayed || audioUnavailable ? '请选择答案' : '先播放音频'),
|
||||
onPressed: onContinue,
|
||||
),
|
||||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||
Text(
|
||||
'再听一次,选择正确答案。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
Text('再听一次,选择正确答案。', style: TextStyle(color: AppColors.warmInk)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,15 +6,23 @@ class _SpeakingStep extends StatefulWidget {
|
||||
required this.segmentId,
|
||||
required this.text,
|
||||
this.tip = '',
|
||||
this.chunks = const [],
|
||||
this.examples = const [],
|
||||
required this.buildIndex,
|
||||
required this.onBuildProgress,
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String segmentId;
|
||||
final String text;
|
||||
final String tip;
|
||||
final List<String> chunks;
|
||||
final List<String> examples;
|
||||
final int buildIndex;
|
||||
final ValueChanged<int> onBuildProgress;
|
||||
final AppState state;
|
||||
final bool keepRecording;
|
||||
final VoidCallback onContinue;
|
||||
final ValueChanged<bool> onContinue;
|
||||
|
||||
@override
|
||||
State<_SpeakingStep> createState() => _SpeakingStepState();
|
||||
@@ -89,12 +97,57 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
if (widget.tip.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
widget.tip,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
child: Text(widget.tip, style: TextStyle(color: AppColors.warmInk)),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
if (widget.chunks.length >= 2)
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('拆开练(可选)'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.buildIndex == 0
|
||||
? '先听词块,再逐步合成整句。'
|
||||
: widget.chunks.take(widget.buildIndex).join(' '),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final next = widget.buildIndex < widget.chunks.length
|
||||
? widget.buildIndex + 1
|
||||
: 1;
|
||||
await VoiceService.instance.speak(
|
||||
widget.chunks.take(next).join(' '),
|
||||
);
|
||||
widget.onBuildProgress(next);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(
|
||||
widget.buildIndex == widget.chunks.length
|
||||
? '从头再练'
|
||||
: '听下一段并跟读',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.examples.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: Text('更多示范句(${widget.examples.length})'),
|
||||
children: [
|
||||
for (final example in widget.examples)
|
||||
ListTile(
|
||||
title: LexiconText(example, state: widget.state),
|
||||
trailing: IconButton(
|
||||
tooltip: '播放示范句',
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
onPressed: () => VoiceService.instance.speak(example),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
@@ -146,8 +199,10 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
||||
onPressed: widget.onContinue,
|
||||
label: recordingPath == null ? '跳过跟读,继续' : '录音完成,继续',
|
||||
onPressed: (listening || transcribing)
|
||||
? null
|
||||
: () => widget.onContinue(recordingPath != null),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -34,23 +34,18 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
String? aiFeedbackError;
|
||||
bool requestingAiFeedback = false;
|
||||
|
||||
void _checkOrContinue() {
|
||||
if (result?.complete == true) {
|
||||
widget.onContinue(aiFeedback != null);
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
() => result = WritingFeedback.check(
|
||||
widget.segmentId,
|
||||
widget.controller.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAiFeedback() async {
|
||||
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
||||
Future<void> _submit() async {
|
||||
if (!widget.canContinue || requestingAiFeedback) return;
|
||||
final answer = widget.controller.text.trim();
|
||||
final local = WritingFeedback.check(widget.segmentId, answer);
|
||||
setState(() {
|
||||
result = local;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
if (!local.complete) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||
widget.onContinue(false);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -64,19 +59,28 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
model: widget.state.aiModel,
|
||||
lessonId: widget.lessonId,
|
||||
taskPrompt: widget.activity.writingPrompt,
|
||||
answer: widget.controller.text.trim(),
|
||||
answer: answer,
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!mounted || widget.controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => requestingAiFeedback = false);
|
||||
return;
|
||||
}
|
||||
if (feedback?.verdict == 'accepted') {
|
||||
widget.onContinue(true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
requestingAiFeedback = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
||||
aiFeedbackError = feedback == null || feedback.verdict == 'uncertain'
|
||||
? '答案暂时无法核验。可以重试,或仅保存这次练习继续学习。'
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
void _continueUnverified() => widget.onContinue(false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 5,
|
||||
@@ -87,6 +91,11 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
widget.activity.writingPrompt,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||
@@ -126,19 +135,6 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
onPressed: widget.onToggleHelp,
|
||||
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.canContinue && !requestingAiFeedback
|
||||
? _requestAiFeedback
|
||||
: null,
|
||||
icon: requestingAiFeedback
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'),
|
||||
),
|
||||
if (aiFeedback != null)
|
||||
SectionCard(
|
||||
tint: aiFeedback!.verdict == 'accepted'
|
||||
@@ -153,7 +149,9 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
if (aiFeedback!.suggestion != null)
|
||||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||
Text(
|
||||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||||
aiFeedback!.verdict == 'rewrite'
|
||||
? '请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'
|
||||
: '答案尚未核验;可以重试检查或仅保存练习。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -167,20 +165,25 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (result != null)
|
||||
if (result != null && !result!.complete)
|
||||
SectionCard(
|
||||
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
result!.message,
|
||||
style: TextStyle(
|
||||
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
||||
),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
||||
onPressed: widget.canContinue ? _checkOrContinue : null,
|
||||
label: requestingAiFeedback ? '正在自动检查…' : '提交并继续',
|
||||
onPressed: widget.canContinue && !requestingAiFeedback
|
||||
? _submit
|
||||
: null,
|
||||
),
|
||||
if (aiFeedbackError != null)
|
||||
TextButton(
|
||||
onPressed: _continueUnverified,
|
||||
child: const Text('未核验,保存练习并继续'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -147,13 +147,21 @@ class _PlacementPageState extends State<PlacementPage>
|
||||
bool get _answeredName =>
|
||||
coreItemUsedIn('A0-P01', answerController.text.trim());
|
||||
|
||||
/// Placement only picks an A0 starting point until A1 content is frozen, and
|
||||
/// it never marks a lesson as passed.
|
||||
String get _suggestedLessonId =>
|
||||
_understoodGreeting && _answeredName ? 'a0-02' : 'a0-01';
|
||||
/// Self report only moves the starting point when both short checks pass.
|
||||
/// Placement never marks the earlier lessons as completed.
|
||||
String get _suggestedLessonId {
|
||||
if (!_understoodGreeting || !_answeredName) return 'a0-01';
|
||||
return switch (selected) {
|
||||
PlacementLevel.beginner => 'a0-02',
|
||||
PlacementLevel.someBasics => 'a0-03',
|
||||
PlacementLevel.simpleConversation => 'a0-04',
|
||||
};
|
||||
}
|
||||
|
||||
String get _reason => _understoodGreeting && _answeredName
|
||||
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
||||
? selected == PlacementLevel.beginner
|
||||
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
||||
: '你通过了两项基础检查,结合自选基础,建议从这课试学;前面的课随时可以返回。'
|
||||
: _understoodGreeting
|
||||
? '你能听懂问候,先把自我介绍说完整会更稳。'
|
||||
: '先从问候和介绍名字开始,打好第一句的基础。';
|
||||
|
||||
@@ -30,6 +30,18 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = <String>{};
|
||||
final improved = <String, AttemptEvidence>{};
|
||||
for (final entry in widget.state.attemptEvidence) {
|
||||
final key = '${entry.itemId}|${entry.skill}';
|
||||
if (entry.outcome == EvidenceKind.assisted ||
|
||||
entry.outcome == EvidenceKind.languageError) {
|
||||
progress.add(key);
|
||||
} else if (entry.outcome == EvidenceKind.independentSuccess &&
|
||||
progress.contains(key)) {
|
||||
improved[key] = entry;
|
||||
}
|
||||
}
|
||||
final records = widget.state.attemptEvidence.reversed
|
||||
.where(_matches)
|
||||
.take(_limit)
|
||||
@@ -41,6 +53,21 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
||||
return SpacedColumn(
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (improved.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('从需要帮助到独立完成'),
|
||||
const SizedBox(height: 6),
|
||||
for (final entry in improved.values.toList().reversed.take(3))
|
||||
Text(
|
||||
'${widget.state.mastery[entry.itemId]?.label ?? entry.itemId} · ${entry.skill}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<_RecordFilter>(
|
||||
@@ -100,7 +127,7 @@ class _RecordTile extends StatelessWidget {
|
||||
),
|
||||
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
|
||||
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
|
||||
EvidenceKind.pending => ('稍后完成', AppColors.muted, AppColors.surfaceMuted),
|
||||
EvidenceKind.pending => ('待确认', AppColors.muted, AppColors.surfaceMuted),
|
||||
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
|
||||
};
|
||||
return ListTile(
|
||||
@@ -110,7 +137,19 @@ class _RecordTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text('${evidence.skill} · ${relativeTime(evidence.createdAt)}'),
|
||||
subtitle: Text(
|
||||
[
|
||||
evidence.skill,
|
||||
relativeTime(evidence.createdAt),
|
||||
if (evidence.listeningSupport == ListeningSupport.slow) '慢听后完成',
|
||||
if (evidence.listeningSupport == ListeningSupport.transcript)
|
||||
'看原文后完成',
|
||||
if (evidence.firstTry == true) '首次答对',
|
||||
if (evidence.attemptCount != null && evidence.attemptCount! > 1)
|
||||
'尝试 ${evidence.attemptCount} 次',
|
||||
if (evidence.usedSentenceBuild) '练过句子拆段',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -22,7 +22,7 @@ class LevelProgressView extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
state.a0Passed ? 'A1 主线内容尚未提供,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
||||
state.a0Passed ? 'A1 课程待教学审校开放,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
||||
),
|
||||
MenuGroup(
|
||||
children: [
|
||||
|
||||
@@ -306,7 +306,7 @@ class _StageCard extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
missing.isEmpty
|
||||
? 'A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'
|
||||
? 'A0 已通过。A1 课程待教学审校开放,可继续进行 A0 巩固。'
|
||||
: '还需:${missing.join(' · ')}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
final controller = TextEditingController();
|
||||
bool showHint = false;
|
||||
bool usedHelp = false;
|
||||
int answerChecks = 0;
|
||||
String? validationMessage;
|
||||
bool generatingVariant = false;
|
||||
bool generatingLesson = false;
|
||||
@@ -50,6 +51,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
/// The answer just accepted, shown before moving to the next item.
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
ListeningSupport dictationSupport = ListeningSupport.none;
|
||||
|
||||
/// Items whose recognition question has been answered in this session. The
|
||||
/// gate is asked once per item; the production task follows immediately.
|
||||
@@ -77,21 +79,23 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
answerChecks = 0;
|
||||
validationMessage = null;
|
||||
dictationDiff = null;
|
||||
transcript = null;
|
||||
recordingPath = null;
|
||||
dictationPlayed = false;
|
||||
dictationSupport = ListeningSupport.none;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(ReviewItem item) async {
|
||||
Future<WritingAiFeedback?> _checkWithAi(ReviewItem item) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (answer.isEmpty || checkingWithAi) return null;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
|
||||
return;
|
||||
setState(() => aiFeedbackError = '未配置 AI 服务;本次只记为练习,不增加独立掌握。');
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
@@ -111,28 +115,46 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
// The learner may have moved on or kept typing while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
||||
? '暂时无法获得 AI 检查结果;本次只记为练习,不增加独立掌握。'
|
||||
: null;
|
||||
// A shown correction is help: finishing afterwards counts as assisted.
|
||||
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
||||
});
|
||||
return feedback;
|
||||
}
|
||||
|
||||
void _next(ReviewItem item, {required bool assisted}) {
|
||||
Future<void> _next(ReviewItem item, {required bool assisted}) async {
|
||||
if (checkingWithAi) return;
|
||||
answerChecks++;
|
||||
final result = ReviewFeedback.check(item, controller.text);
|
||||
if (!result.complete) {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
validationMessage = result.message;
|
||||
dictationDiff = result.dictation;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item.skill != dictationSkill) {
|
||||
final answerBeforeCheck = controller.text.trim();
|
||||
final feedback = await _checkWithAi(item);
|
||||
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
validationMessage = '请根据 AI 反馈修改句子,再提交。';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feedback?.verdict != 'accepted') assisted = true;
|
||||
}
|
||||
assisted = assisted || usedHelp || answerChecks > 1;
|
||||
final answer = controller.text.trim();
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
@@ -142,6 +164,9 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
widget.state.completeReview(
|
||||
item,
|
||||
assisted: assisted,
|
||||
firstTry: answerChecks == 1,
|
||||
attemptCount: answerChecks,
|
||||
listeningSupport: dictation == null ? null : dictationSupport,
|
||||
rawAnswer: answer,
|
||||
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
|
||||
originalTranscript: transcript,
|
||||
@@ -221,7 +246,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
children: [
|
||||
Text(english, style: const TextStyle(fontSize: 22)),
|
||||
Row(
|
||||
children: [
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(audio),
|
||||
@@ -269,7 +294,11 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
// Being told the answer is the moment to say what the item is for. A
|
||||
// learner who could not produce `What's your name?` often knows the words
|
||||
// and not the situation.
|
||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
||||
if (coreContrastQuestion(
|
||||
item.id,
|
||||
isTaught: widget.state.mastery.containsKey,
|
||||
) !=
|
||||
null) {
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
contrastFor = item;
|
||||
@@ -298,14 +327,14 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('先弄清楚什么时候用'),
|
||||
Text(
|
||||
'这句话用在这种情况:',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text('这句话用在这种情况:', style: Theme.of(context).textTheme.headlineMedium),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
||||
child: Text(
|
||||
question.situation,
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
),
|
||||
const Text('这时候该说哪一句?'),
|
||||
for (final option in question.options)
|
||||
@@ -378,7 +407,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
result.assisted ? '带提示完成' : '答对了!',
|
||||
result.assisted ? '完成练习(未计独立掌握)' : '答对了!',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
@@ -439,11 +468,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
}
|
||||
|
||||
Future<void> _play(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await VoiceService.instance.speak(text, slow: slow);
|
||||
} finally {
|
||||
if (mounted) setState(() => dictationPlayed = true);
|
||||
}
|
||||
final played = await VoiceService.instance.speak(text, slow: slow);
|
||||
if (played && mounted) setState(() => dictationPlayed = true);
|
||||
}
|
||||
|
||||
Future<void> _generateVariant(ReviewItem item) async {
|
||||
@@ -629,7 +655,21 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
|
||||
TextButton(
|
||||
onPressed: () => _play(dictation.sentence, slow: true),
|
||||
onPressed: dictationPlayed
|
||||
? () async {
|
||||
final played = await VoiceService.instance
|
||||
.speak(dictation.sentence, slow: true);
|
||||
if (played && mounted) {
|
||||
setState(() {
|
||||
if (dictationSupport !=
|
||||
ListeningSupport.transcript) {
|
||||
dictationSupport = ListeningSupport.slow;
|
||||
}
|
||||
usedHelp = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
@@ -682,6 +722,9 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
onPressed: () => setState(() {
|
||||
showHint = true;
|
||||
usedHelp = true;
|
||||
if (dictation != null) {
|
||||
dictationSupport = ListeningSupport.transcript;
|
||||
}
|
||||
validationMessage = null;
|
||||
}),
|
||||
),
|
||||
@@ -729,6 +772,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
: '参考:${item.hint}\n目标:${item.target}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (dictation != null) Text('原文:${dictation.sentence}'),
|
||||
if (audioText != null && dictation == null)
|
||||
TextButton.icon(
|
||||
onPressed: () => _play(audioText),
|
||||
@@ -739,20 +783,12 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Dictation is already compared word by word against the sentence.
|
||||
if (dictation == null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _checkWithAi(item),
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
||||
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||
if (dictation == null &&
|
||||
widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。可继续复习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
if (aiFeedback != null)
|
||||
SectionCard(
|
||||
@@ -768,7 +804,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
Text('参考改正:${aiFeedback!.suggestion}'),
|
||||
Text(
|
||||
aiFeedback!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
||||
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||
: '看过改正后再提交,本次会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -790,8 +826,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
),
|
||||
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
|
||||
PrimaryButton(
|
||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
label: usedHelp ? '带提示完成' : '检查并提交',
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _next(item, assisted: usedHelp),
|
||||
),
|
||||
@@ -872,6 +908,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
/// The answer passed the local check; the next tap records it.
|
||||
bool answerAccepted = false;
|
||||
bool checkingWithAi = false;
|
||||
bool aiVerified = false;
|
||||
WritingAiFeedback? aiCheck;
|
||||
String? aiCheckError;
|
||||
|
||||
@@ -925,7 +962,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
);
|
||||
}
|
||||
|
||||
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
|
||||
Future<void> _advance(
|
||||
GeneratedLesson lesson,
|
||||
GeneratedLessonTask task,
|
||||
) async {
|
||||
final shortfall = adaptiveAnswerShortfall(task, controller.text);
|
||||
final correct = shortfall == null;
|
||||
if (!showReference && !correct) {
|
||||
@@ -934,6 +974,18 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
}
|
||||
// Show the verdict before moving on, so the learner sees it.
|
||||
if (!answerAccepted) {
|
||||
if (correct &&
|
||||
!showReference &&
|
||||
(task.skill == 'writing' || task.skill == 'speaking')) {
|
||||
final answerBeforeCheck = controller.text.trim();
|
||||
final feedback = await _checkWithAi(task);
|
||||
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
setState(() => answerFeedback = '请根据 AI 反馈修改句子,再检查。');
|
||||
return;
|
||||
}
|
||||
aiVerified = feedback?.verdict == 'accepted';
|
||||
}
|
||||
setState(() {
|
||||
answerAccepted = true;
|
||||
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
|
||||
@@ -944,7 +996,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
lesson: lesson,
|
||||
task: task,
|
||||
rawAnswer: controller.text.trim(),
|
||||
assisted: showReference || aiCorrected,
|
||||
assisted:
|
||||
showReference ||
|
||||
aiCorrected ||
|
||||
((task.skill == 'writing' || task.skill == 'speaking') &&
|
||||
!aiVerified),
|
||||
correct: correct,
|
||||
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
||||
? 'speechToText'
|
||||
@@ -988,16 +1044,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
|
||||
void _clearChecks() {
|
||||
answerAccepted = false;
|
||||
aiVerified = false;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(GeneratedLessonTask task) async {
|
||||
Future<WritingAiFeedback?> _checkWithAi(GeneratedLessonTask task) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (answer.isEmpty || checkingWithAi) return null;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
|
||||
return;
|
||||
setState(() => aiCheckError = '未配置 AI 服务;本次仅作教学练习。');
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
@@ -1016,18 +1073,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
// The learner may have kept typing or moved on while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
|
||||
: null;
|
||||
aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果;本次仅作教学练习。' : null;
|
||||
if (feedback != null && feedback.verdict != 'accepted') {
|
||||
aiCorrected = true;
|
||||
}
|
||||
});
|
||||
return feedback;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1248,20 +1304,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (task.skill == 'writing' || task.skill == 'speaking')
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _checkWithAi(task),
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
||||
),
|
||||
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
@@ -1276,7 +1319,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否正确仍由本地检查判断。'
|
||||
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||
: '看过改正后再继续,本项会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -1304,7 +1347,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
Icon(Icons.check_circle, color: AppColors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
|
||||
showReference ||
|
||||
aiCorrected ||
|
||||
((task.skill == 'writing' ||
|
||||
task.skill == 'speaking') &&
|
||||
!aiVerified)
|
||||
? '完成练习(不计独立掌握)'
|
||||
: '答对了!',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1346,7 +1395,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
: index == lesson.tasks.length - 1
|
||||
? '完成补练'
|
||||
: '下一项',
|
||||
onPressed: controller.text.trim().isNotEmpty
|
||||
onPressed: controller.text.trim().isNotEmpty && !checkingWithAi
|
||||
? () => _advance(lesson, task)
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -175,42 +175,42 @@ class _LearningShellState extends State<LearningShell> {
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
height: 70,
|
||||
indicatorColor: AppColors.softGreen,
|
||||
onDestinationSelected: (index) =>
|
||||
showTab(AppTab.values[index]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: "学习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: "对话",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: "复习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: "我的",
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
height: 70,
|
||||
indicatorColor: AppColors.softGreen,
|
||||
onDestinationSelected: (index) =>
|
||||
showTab(AppTab.values[index]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: "学习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: "对话",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: "复习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: "我的",
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -223,8 +223,9 @@ class _LearningShellState extends State<LearningShell> {
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartLesson: () {
|
||||
if (widget.state.completedLessonIds
|
||||
.contains(widget.state.activeLessonId)) {
|
||||
if (widget.state.completedLessonIds.contains(
|
||||
widget.state.activeLessonId,
|
||||
)) {
|
||||
final next = widget.state.nextIncompleteLessonId;
|
||||
if (next != null) widget.state.openLesson(next);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,8 @@ class _WordsPageState extends State<WordsPage> {
|
||||
),
|
||||
// The sentence is the point of the feedback: a gloss alone is
|
||||
// what did not stick the first time.
|
||||
if (WordBank.instance.exampleOf(question.id) case final sample?) ...[
|
||||
if (WordBank.instance.exampleOf(question.id)
|
||||
case final sample?) ...[
|
||||
Text(
|
||||
sample.en,
|
||||
style: TextStyle(
|
||||
@@ -278,7 +279,8 @@ class _WordsPageState extends State<WordsPage> {
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in shaky.take(8)) _WordRow(id: id, state: state),
|
||||
for (final id in shaky.take(8))
|
||||
_WordRow(id: id, state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -334,7 +336,10 @@ class _WordRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
wordEnglish(id),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
wordIpa(id).isEmpty
|
||||
@@ -345,14 +350,11 @@ class _WordRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
},
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
}, style: Theme.of(context).textTheme.bodySmall),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
||||
|
||||
Reference in New Issue
Block a user