refactor: 将课程各步骤拆分到 features/lesson/steps/
lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达 六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _IndependentStep extends StatefulWidget {
|
||||||
|
const _IndependentStep({
|
||||||
|
required this.state,
|
||||||
|
required this.segmentId,
|
||||||
|
required this.keepRecording,
|
||||||
|
required this.activity,
|
||||||
|
required this.controller,
|
||||||
|
required this.showHelp,
|
||||||
|
required this.canContinue,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onNeedHelp,
|
||||||
|
required this.onLookup,
|
||||||
|
required this.onContinue,
|
||||||
|
required this.onLater,
|
||||||
|
});
|
||||||
|
final AppState state;
|
||||||
|
final LessonActivity activity;
|
||||||
|
final String segmentId;
|
||||||
|
final bool keepRecording;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool showHelp;
|
||||||
|
final bool canContinue;
|
||||||
|
final VoidCallback onChanged;
|
||||||
|
final VoidCallback onNeedHelp;
|
||||||
|
final VoidCallback onLookup;
|
||||||
|
final void Function(bool spoken, String? recordingPath) onContinue;
|
||||||
|
final VoidCallback onLater;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_IndependentStep> createState() => _IndependentStepState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IndependentStepState extends State<_IndependentStep>
|
||||||
|
with VoiceAnswerMixin<_IndependentStep> {
|
||||||
|
bool usedVoice = false;
|
||||||
|
bool transcriptEdited = false;
|
||||||
|
String lastTranscript = '';
|
||||||
|
String? validationError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppState get voiceState => widget.state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
disposeVoiceAnswer(keepRecording: widget.keepRecording);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
|
||||||
|
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
widget.onContinue(
|
||||||
|
usedVoice && !transcriptEdited,
|
||||||
|
widget.keepRecording ? recordingPath : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleMic() async {
|
||||||
|
if (aiVoiceRecording) {
|
||||||
|
await finishVoiceInput(
|
||||||
|
onTranscript: (text) {
|
||||||
|
widget.controller.text = text;
|
||||||
|
usedVoice = true;
|
||||||
|
lastTranscript = text;
|
||||||
|
transcriptEdited = false;
|
||||||
|
},
|
||||||
|
afterTranscribe: widget.onChanged,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listening) {
|
||||||
|
await VoiceService.instance.stopListening();
|
||||||
|
if (mounted) setState(() => listening = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ready = await VoiceService.instance.startListening(
|
||||||
|
(text, _) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
widget.controller.text = text;
|
||||||
|
usedVoice = true;
|
||||||
|
lastTranscript = text;
|
||||||
|
transcriptEdited = false;
|
||||||
|
});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
onStatus: (status) {
|
||||||
|
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||||
|
setState(() => listening = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => listening = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
final recordStarted = await startVoiceInput();
|
||||||
|
if (recordStarted && mounted) {
|
||||||
|
showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) setState(() => listening = ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _LessonScaffold(
|
||||||
|
step: 6,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||||||
|
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
Text(widget.activity.independentPrompt),
|
||||||
|
if (widget.showHelp)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
'帮助:${widget.activity.independentHelp}',
|
||||||
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (usedVoice && value != lastTranscript) transcriptEdited = true;
|
||||||
|
setState(() {});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
minLines: 2,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '输入完整英文句子',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
prefixIcon: IconButton(
|
||||||
|
tooltip: listening ? '停止录音' : '语音输入',
|
||||||
|
icon: Icon(
|
||||||
|
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||||
|
),
|
||||||
|
onPressed: _toggleMic,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: SpacedColumn(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.keepRecording
|
||||||
|
? '可录下这次尝试并保存在本机;不会发送给 AI。'
|
||||||
|
: '可录下这次尝试并回听;离开本页后会自动删除。',
|
||||||
|
),
|
||||||
|
RecordingControls(
|
||||||
|
recording: recording,
|
||||||
|
playing: playingRecording,
|
||||||
|
hasRecording: recordingPath != null,
|
||||||
|
onToggleRecording: toggleRecording,
|
||||||
|
onPlay: playRecording,
|
||||||
|
onDelete: deleteRecording,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (usedVoice)
|
||||||
|
SectionCard(
|
||||||
|
tint: transcriptEdited ? AppColors.warm : AppColors.softGreen,
|
||||||
|
child: Text(
|
||||||
|
transcriptEdited
|
||||||
|
? '你修改了设备转写:这次会按文字练习保存,不计口语练习。'
|
||||||
|
: '这是设备转写。未修改并确认后,会保留为本次语音练习记录。',
|
||||||
|
style: TextStyle(
|
||||||
|
color: transcriptEdited ? AppColors.warmInk : AppColors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (validationError != null)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
validationError!,
|
||||||
|
style: const TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!widget.showHelp)
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onNeedHelp,
|
||||||
|
child: const Text('需要帮助'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onLookup,
|
||||||
|
child: const Text('查词或短语'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||||||
|
PrimaryButton(
|
||||||
|
label: widget.showHelp ? '带帮助完成' : '独立完成',
|
||||||
|
onPressed: widget.canContinue ? _submit : null,
|
||||||
|
),
|
||||||
|
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _ListeningStep extends StatelessWidget {
|
||||||
|
const _ListeningStep({
|
||||||
|
required this.state,
|
||||||
|
required this.activity,
|
||||||
|
required this.options,
|
||||||
|
required this.correctAnswer,
|
||||||
|
required this.selectedAnswer,
|
||||||
|
required this.audioPlayed,
|
||||||
|
required this.onSelected,
|
||||||
|
required this.onPlayed,
|
||||||
|
required this.onLookup,
|
||||||
|
required this.onContinue,
|
||||||
|
});
|
||||||
|
|
||||||
|
final LessonActivity activity;
|
||||||
|
final AppState state;
|
||||||
|
final List<String> options;
|
||||||
|
final String correctAnswer;
|
||||||
|
|
||||||
|
final int selectedAnswer;
|
||||||
|
final bool audioPlayed;
|
||||||
|
final ValueChanged<int> onSelected;
|
||||||
|
final VoidCallback onPlayed;
|
||||||
|
final VoidCallback onLookup;
|
||||||
|
final VoidCallback? onContinue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final answers = options;
|
||||||
|
return _LessonScaffold(
|
||||||
|
step: 2,
|
||||||
|
child: SpacedColumn(
|
||||||
|
spacing: 14,
|
||||||
|
children: [
|
||||||
|
const Eyebrow('听一听'),
|
||||||
|
Text(
|
||||||
|
activity.listeningQuestion,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: _AudioRow(
|
||||||
|
label: audioPlayed ? '再播放一次' : '播放问题',
|
||||||
|
speech: activity.listening,
|
||||||
|
onPlayed: onPlayed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onLookup,
|
||||||
|
icon: const Icon(Icons.menu_book_outlined),
|
||||||
|
label: const Text('查看词或短语'),
|
||||||
|
),
|
||||||
|
if (audioPlayed)
|
||||||
|
SectionCard(child: LexiconText(activity.listening, state: state)),
|
||||||
|
for (var index = 0; index < answers.length; index++)
|
||||||
|
SectionCard(
|
||||||
|
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||||
|
onTap: () {
|
||||||
|
onSelected(index);
|
||||||
|
if (!audioPlayed) {
|
||||||
|
onPlayed();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
selectedAnswer == index
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_off,
|
||||||
|
color: selectedAnswer == index
|
||||||
|
? AppColors.green
|
||||||
|
: AppColors.muted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(answers[index]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: selectedAnswer >= 0
|
||||||
|
? '检查并继续'
|
||||||
|
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
|
||||||
|
onPressed: onContinue,
|
||||||
|
),
|
||||||
|
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||||
|
const Text(
|
||||||
|
'再听一次,选择正确答案。',
|
||||||
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _PreviewStep extends StatelessWidget {
|
||||||
|
const _PreviewStep({
|
||||||
|
required this.state,
|
||||||
|
required this.item,
|
||||||
|
required this.position,
|
||||||
|
required this.total,
|
||||||
|
required this.onLookup,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onSkip,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VocabularyItem item;
|
||||||
|
final AppState state;
|
||||||
|
final int position;
|
||||||
|
final int total;
|
||||||
|
final VoidCallback onLookup;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final VoidCallback onSkip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _LessonScaffold(
|
||||||
|
step: 1,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
Eyebrow('先认识今天的词 · $position / $total'),
|
||||||
|
Text('后面会遇到这些词。', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
const Text('先听一遍、知道意思就够了,不用马上背会。'),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.softGreen,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.word,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.ipa != null)
|
||||||
|
Text(item.ipa!, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
Text(item.meaning, style: const TextStyle(fontSize: 17)),
|
||||||
|
_AudioRow(label: '播放示范音', speech: item.word),
|
||||||
|
LexiconText(item.example, state: state),
|
||||||
|
Text(item.exampleMeaning),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: position == total ? '进入课程' : '认识了,下一个',
|
||||||
|
onPressed: onNext,
|
||||||
|
),
|
||||||
|
TextButton(onPressed: onSkip, child: const Text('跳过,直接进入课程')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _ReadingStep extends StatefulWidget {
|
||||||
|
const _ReadingStep({
|
||||||
|
required this.state,
|
||||||
|
required this.activity,
|
||||||
|
required this.onLookup,
|
||||||
|
required this.onContinue,
|
||||||
|
});
|
||||||
|
final LessonActivity activity;
|
||||||
|
final AppState state;
|
||||||
|
final VoidCallback onLookup;
|
||||||
|
final VoidCallback onContinue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReadingStep> createState() => _ReadingStepState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReadingStepState extends State<_ReadingStep> {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
int? selectedOptionIndex;
|
||||||
|
bool showAnswer = false;
|
||||||
|
|
||||||
|
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
|
||||||
|
late final List<String> options = shuffledOptions(
|
||||||
|
widget.activity.readingOptions,
|
||||||
|
'${widget.activity.readingQuestion}-reading',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isOptionCorrect(int index) {
|
||||||
|
if (options.isEmpty || index < 0 || index >= options.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final option = options[index].trim();
|
||||||
|
final answer = widget.activity.readingAnswer.trim();
|
||||||
|
if (option.toLowerCase() == answer.toLowerCase()) return true;
|
||||||
|
|
||||||
|
final normOption = option.toLowerCase().replaceAll(
|
||||||
|
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
final normAnswer = answer.toLowerCase().replaceAll(
|
||||||
|
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return normOption.isNotEmpty &&
|
||||||
|
normAnswer.isNotEmpty &&
|
||||||
|
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isOptionMode => options.isNotEmpty;
|
||||||
|
|
||||||
|
bool get isCorrect {
|
||||||
|
if (isOptionMode) {
|
||||||
|
return selectedOptionIndex != null &&
|
||||||
|
_isOptionCorrect(selectedOptionIndex!);
|
||||||
|
}
|
||||||
|
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
|
||||||
|
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
final response = controller.text.toLowerCase().replaceAll(
|
||||||
|
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
|
||||||
|
// 也能判对('a' 通过 'A book')。
|
||||||
|
return response.isNotEmpty &&
|
||||||
|
answer.isNotEmpty &&
|
||||||
|
response.contains(answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasSelected = selectedOptionIndex != null;
|
||||||
|
final answeredCorrectly = isCorrect;
|
||||||
|
|
||||||
|
return _LessonScaffold(
|
||||||
|
step: 4,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
const Eyebrow('读一读'),
|
||||||
|
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.softGreen,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.green,
|
||||||
|
),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'对话内容',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
VoiceService.instance.speak(widget.activity.reading),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.volume_up_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.green,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'朗读对话',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LexiconText(
|
||||||
|
widget.activity.reading,
|
||||||
|
state: widget.state,
|
||||||
|
style: const TextStyle(fontSize: 16, height: 1.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: widget.onLookup,
|
||||||
|
icon: const Icon(Icons.menu_book_outlined, size: 18),
|
||||||
|
label: const Text('查词或短语'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
final lines = widget.activity.reading.split('\n');
|
||||||
|
final target = lines
|
||||||
|
.firstWhere(
|
||||||
|
(l) => l.trim().isNotEmpty,
|
||||||
|
orElse: () => widget.activity.reading,
|
||||||
|
)
|
||||||
|
.replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
|
||||||
|
showLexiconLookup(
|
||||||
|
context,
|
||||||
|
state: widget.state,
|
||||||
|
initialText: target,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.auto_stories_outlined, size: 18),
|
||||||
|
label: const Text('句型深度解析'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.green.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'问题',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.activity.readingQuestion,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isOptionMode) ...[
|
||||||
|
for (var index = 0; index < options.length; index++) ...[
|
||||||
|
SectionCard(
|
||||||
|
tint: selectedOptionIndex == index
|
||||||
|
? (_isOptionCorrect(index)
|
||||||
|
? AppColors.softGreen
|
||||||
|
: AppColors.warm)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
selectedOptionIndex = index;
|
||||||
|
showAnswer = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
selectedOptionIndex == index
|
||||||
|
? (_isOptionCorrect(index)
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.cancel_outlined)
|
||||||
|
: Icons.radio_button_off,
|
||||||
|
color: selectedOptionIndex == index
|
||||||
|
? (_isOptionCorrect(index)
|
||||||
|
? AppColors.green
|
||||||
|
: AppColors.warmInk)
|
||||||
|
: AppColors.muted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
options[index],
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: selectedOptionIndex == index
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: selectedOptionIndex == index
|
||||||
|
? (_isOptionCorrect(index)
|
||||||
|
? AppColors.green
|
||||||
|
: AppColors.warmInk)
|
||||||
|
: AppColors.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (hasSelected && answeredCorrectly)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.softGreen,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.green.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'回答正确!点击下方按钮继续',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.green,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (hasSelected && !answeredCorrectly)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warm,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.warmInk.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.help_outline,
|
||||||
|
color: AppColors.warmInk,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'不对哦,再仔细观察对话中的关键句子~',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.warmInk,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '用英文输入答案',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (showAnswer)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
'答案:${widget.activity.readingAnswer}',
|
||||||
|
style: const TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!showAnswer &&
|
||||||
|
((isOptionMode && hasSelected && !answeredCorrectly) ||
|
||||||
|
(!isOptionMode &&
|
||||||
|
controller.text.isNotEmpty &&
|
||||||
|
!answeredCorrectly)))
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => showAnswer = true),
|
||||||
|
child: const Text('查看答案后继续学习'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: showAnswer || answeredCorrectly
|
||||||
|
? '继续写一写'
|
||||||
|
: (isOptionMode ? '请选择答案' : '检查并继续'),
|
||||||
|
onPressed: showAnswer || answeredCorrectly
|
||||||
|
? widget.onContinue
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _SpeakingStep extends StatefulWidget {
|
||||||
|
const _SpeakingStep({
|
||||||
|
required this.state,
|
||||||
|
required this.text,
|
||||||
|
required this.keepRecording,
|
||||||
|
required this.onContinue,
|
||||||
|
});
|
||||||
|
final String text;
|
||||||
|
final AppState state;
|
||||||
|
final bool keepRecording;
|
||||||
|
final VoidCallback onContinue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SpeakingStep> createState() => _SpeakingStepState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpeakingStepState extends State<_SpeakingStep>
|
||||||
|
with VoiceAnswerMixin<_SpeakingStep> {
|
||||||
|
String transcript = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppState get voiceState => widget.state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
disposeVoiceAnswer(keepRecording: widget.keepRecording);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleMic() async {
|
||||||
|
if (aiVoiceRecording) {
|
||||||
|
await finishVoiceInput(
|
||||||
|
noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。',
|
||||||
|
onTranscript: (text) => transcript = text,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await VoiceService.instance.stopRecordingPlayback();
|
||||||
|
if (mounted) setState(() => playingRecording = false);
|
||||||
|
if (!widget.keepRecording) {
|
||||||
|
await VoiceService.instance.deleteRecording(recordingPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
final recordStarted = await startVoiceInput();
|
||||||
|
if (!recordStarted || !mounted) return;
|
||||||
|
setState(() => recordingPath = null);
|
||||||
|
showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _togglePlayRecording() async {
|
||||||
|
if (playingRecording) {
|
||||||
|
await VoiceService.instance.stopRecordingPlayback();
|
||||||
|
if (mounted) setState(() => playingRecording = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recordingPath == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await playRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _LessonScaffold(
|
||||||
|
step: 3,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
const Eyebrow('跟读'),
|
||||||
|
Text('先听,再说。', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
LexiconText(
|
||||||
|
widget.text,
|
||||||
|
state: widget.state,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'/es - eɪtʃ - iː - en/',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: _AudioRow(label: '播放示范音', speech: widget.text),
|
||||||
|
),
|
||||||
|
const SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
'字母之间留一个短停顿。先清楚,不必快。',
|
||||||
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SecondaryButton(
|
||||||
|
label: transcribing
|
||||||
|
? '正在 AI 识别发音…'
|
||||||
|
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
|
||||||
|
onPressed: transcribing ? null : _toggleMic,
|
||||||
|
),
|
||||||
|
SecondaryButton(
|
||||||
|
label: playingRecording ? '停止播放' : '播放跟读',
|
||||||
|
onPressed: (listening || transcribing) ? null : _togglePlayRecording,
|
||||||
|
),
|
||||||
|
if (recordingPath != null)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.softGreen,
|
||||||
|
child: SpacedColumn(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: (listening || transcribing)
|
||||||
|
? null
|
||||||
|
: _togglePlayRecording,
|
||||||
|
icon: Icon(
|
||||||
|
playingRecording ? Icons.stop : Icons.play_arrow,
|
||||||
|
),
|
||||||
|
label: Text(playingRecording ? '停止播放' : '播放跟读'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '删除录音',
|
||||||
|
onPressed: (listening || transcribing)
|
||||||
|
? null
|
||||||
|
: deleteRecording,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (transcript.isNotEmpty)
|
||||||
|
SectionCard(child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。")),
|
||||||
|
const SectionCard(
|
||||||
|
child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
||||||
|
onPressed: widget.onContinue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
part of '../lesson_flow.dart';
|
||||||
|
|
||||||
|
class _WritingStep extends StatefulWidget {
|
||||||
|
const _WritingStep({
|
||||||
|
required this.state,
|
||||||
|
required this.lessonId,
|
||||||
|
required this.segmentId,
|
||||||
|
required this.activity,
|
||||||
|
required this.controller,
|
||||||
|
required this.showHelp,
|
||||||
|
required this.canContinue,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onToggleHelp,
|
||||||
|
required this.onContinue,
|
||||||
|
});
|
||||||
|
final String lessonId;
|
||||||
|
final AppState state;
|
||||||
|
final String segmentId;
|
||||||
|
final LessonActivity activity;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool showHelp;
|
||||||
|
final bool canContinue;
|
||||||
|
final VoidCallback onChanged;
|
||||||
|
final VoidCallback onToggleHelp;
|
||||||
|
final ValueChanged<bool> onContinue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_WritingStep> createState() => _WritingStepState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WritingStepState extends State<_WritingStep> {
|
||||||
|
WritingCheckResult? result;
|
||||||
|
WritingAiFeedback? aiFeedback;
|
||||||
|
String? aiFeedbackError;
|
||||||
|
bool requestingAiFeedback = false;
|
||||||
|
|
||||||
|
void _checkOrContinue() {
|
||||||
|
if (result?.complete == true) {
|
||||||
|
widget.onContinue(aiFeedback != null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(
|
||||||
|
() => result = WritingFeedback.check(
|
||||||
|
widget.lessonId,
|
||||||
|
widget.controller.text,
|
||||||
|
segmentId: widget.segmentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestAiFeedback() async {
|
||||||
|
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
||||||
|
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||||
|
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
requestingAiFeedback = true;
|
||||||
|
aiFeedbackError = null;
|
||||||
|
});
|
||||||
|
final feedback = await AiService.instance.writingFeedback(
|
||||||
|
provider: widget.state.aiProvider,
|
||||||
|
endpoint: widget.state.aiEndpoint,
|
||||||
|
model: widget.state.aiModel,
|
||||||
|
lessonId: widget.lessonId,
|
||||||
|
taskPrompt: widget.activity.writingPrompt,
|
||||||
|
answer: widget.controller.text.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
requestingAiFeedback = false;
|
||||||
|
aiFeedback = feedback;
|
||||||
|
aiFeedbackError = feedback == null
|
||||||
|
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _LessonScaffold(
|
||||||
|
step: 5,
|
||||||
|
child: SpacedColumn(
|
||||||
|
children: [
|
||||||
|
const Eyebrow('写一写'),
|
||||||
|
Text(
|
||||||
|
widget.activity.writingPrompt,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: Text(
|
||||||
|
'小提示:${grammarNoteForSegment(widget.segmentId, widget.lessonId)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.showHelp)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.softGreen,
|
||||||
|
child: Text(
|
||||||
|
widget.activity.writingExample,
|
||||||
|
style: TextStyle(fontSize: 18, height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
result = null;
|
||||||
|
aiFeedback = null;
|
||||||
|
aiFeedbackError = null;
|
||||||
|
});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '你的答案',
|
||||||
|
hintText: widget.showHelp
|
||||||
|
? widget.activity.writingExample
|
||||||
|
: '请输入完整英文答案',
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.surface,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
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'
|
||||||
|
? AppColors.softGreen
|
||||||
|
: AppColors.warm,
|
||||||
|
child: SpacedColumn(
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text('AI 建议:${aiFeedback!.feedback}'),
|
||||||
|
if (aiFeedback!.missing.isNotEmpty)
|
||||||
|
Text('还可补充:${aiFeedback!.missing.join('、')}'),
|
||||||
|
if (aiFeedback!.suggestion != null)
|
||||||
|
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||||
|
const Text(
|
||||||
|
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (aiFeedbackError != null)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
aiFeedbackError!,
|
||||||
|
style: const TextStyle(color: AppColors.warmInk),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (result != null)
|
||||||
|
SectionCard(
|
||||||
|
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
||||||
|
child: Text(
|
||||||
|
result!.message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PrimaryButton(
|
||||||
|
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
||||||
|
onPressed: widget.canContinue ? _checkOrContinue : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user