Files
English/kouyu_english/lib/features/lesson/lesson_flow.dart
T

1327 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/voice_service.dart';
import '../../core/writing_feedback.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
class LessonFlow extends StatefulWidget {
const LessonFlow({
super.key,
required this.state,
required this.onOpenDialogue,
required this.onFinish,
});
final AppState state;
final VoidCallback onOpenDialogue;
final VoidCallback onFinish;
@override
State<LessonFlow> createState() => _LessonFlowState();
}
class _LessonFlowState extends State<LessonFlow> {
final writingController = TextEditingController();
final independentController = TextEditingController();
int selectedAnswer = -1;
int previewIndex = 0;
bool listeningAudioPlayed = false;
bool showWritingHelp = true;
bool showIndependentHelp = false;
LessonSegment get segment {
final lesson = lessonById(widget.state.activeLessonId);
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
}
LessonActivity get activity =>
activityBySegmentId(segment.id, widget.state.activeLessonId);
List<VocabularyItem> get previewItems =>
vocabularyBySegmentId(segment.id, widget.state.activeLessonId);
@override
void initState() {
super.initState();
writingController.text = widget.state.lessonWritingDraft;
independentController.text = widget.state.independentAttemptDraft;
previewIndex = widget.state.previewIndex
.clamp(0, previewItems.length - 1)
.toInt();
}
@override
void dispose() {
writingController.dispose();
independentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final lesson = lessonById(widget.state.activeLessonId);
final Widget content;
switch (widget.state.lessonStep) {
case LessonStep.preview:
content = _PreviewStep(
state: widget.state,
item: previewItems[previewIndex],
position: previewIndex + 1,
total: previewItems.length,
onLookup: () => showLexiconLookup(
context,
state: widget.state,
initialText: previewItems[previewIndex].word,
),
onNext: () {
if (previewIndex < previewItems.length - 1) {
setState(() => previewIndex += 1);
widget.state.setPreviewIndex(previewIndex);
} else {
widget.state.completePreview();
}
},
onSkip: widget.state.completePreview,
);
case LessonStep.listening:
content = _ListeningStep(
state: widget.state,
activity: activity,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
onSelected: (value) => setState(() => selectedAnswer = value),
onPlayed: () => setState(() => listeningAudioPlayed = true),
onLookup: () => showLexiconLookup(
context,
state: widget.state,
initialText: activity.listening,
),
onContinue: selectedAnswer == 0
? widget.state.completeListening
: null,
);
case LessonStep.speaking:
content = _SpeakingStep(
state: widget.state,
text: activity.speaking,
keepRecording: widget.state.keepRecordings,
onContinue: () => widget.state.completeSpeaking(assisted: true),
);
case LessonStep.reading:
content = _ReadingStep(
state: widget.state,
activity: activity,
onLookup: () => showLexiconLookup(
context,
state: widget.state,
initialText: activity.reading,
),
onContinue: widget.state.completeReading,
);
case LessonStep.writing:
content = _WritingStep(
state: widget.state,
lessonId: widget.state.activeLessonId,
segmentId: segment.id,
activity: activity,
controller: writingController,
showHelp: showWritingHelp,
canContinue: writingController.text.trim().isNotEmpty,
onChanged: () {
widget.state.setLessonWritingDraft(writingController.text);
setState(() {});
},
onToggleHelp: () =>
setState(() => showWritingHelp = !showWritingHelp),
onContinue: (usedAiFeedback) => widget.state.completeWriting(
assisted: showWritingHelp || usedAiFeedback,
rawAnswer: writingController.text.trim(),
),
);
case LessonStep.dialogue:
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
case LessonStep.independent:
content = _IndependentStep(
state: widget.state,
segmentId: segment.id,
keepRecording: widget.state.keepRecordings,
activity: activity,
controller: independentController,
showHelp: showIndependentHelp,
canContinue: independentController.text.trim().isNotEmpty,
onChanged: () {
widget.state.setIndependentAttemptDraft(independentController.text);
setState(() {});
},
onNeedHelp: () => setState(() => showIndependentHelp = true),
onLookup: () {
setState(() => showIndependentHelp = true);
showLexiconLookup(
context,
state: widget.state,
initialText: activity.independentPrompt,
);
},
onContinue: (spoken, recordingPath) =>
widget.state.completeIndependentAttempt(
assisted: showIndependentHelp,
spoken: spoken,
rawAnswer: independentController.text.trim(),
recordingPath: recordingPath,
),
onLater: () =>
widget.state.completeIndependentAttempt(assisted: true),
);
case LessonStep.complete:
content = _CompletionStep(
assisted: widget.state.independentAttemptAssisted,
isFinalSegment:
widget.state.activeSegmentIndexFor(lesson.id) ==
lesson.segments.length - 1,
nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2,
onFinish: () {
widget.state.finishCurrentLessonSegment();
widget.onFinish();
},
);
}
return _LessonScope(
title:
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
onExit: widget.onFinish,
child: content,
);
}
}
class _LessonScaffold extends StatelessWidget {
const _LessonScaffold({required this.step, required this.child});
final int step;
final Widget child;
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出课程",
onPressed: () {
final onExit = _LessonScope.exitOf(context);
if (onExit != null) {
onExit();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: Text(_LessonScope.of(context)),
),
child: SpacedColumn(
spacing: 16,
children: [
Row(
children: List.generate(
6,
(index) => Expanded(
child: Container(
height: 6,
margin: EdgeInsets.only(right: index == 5 ? 0 : 5),
decoration: BoxDecoration(
color: index < step ? AppColors.green : AppColors.line,
borderRadius: BorderRadius.circular(20),
),
),
),
),
),
child,
],
),
);
}
class _LessonScope extends InheritedWidget {
const _LessonScope({
required this.title,
this.onExit,
required super.child,
});
final String title;
final VoidCallback? onExit;
static String of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ??
'A0 课程练习';
static VoidCallback? exitOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
@override
bool updateShouldNotify(_LessonScope oldWidget) =>
title != oldWidget.title || onExit != oldWidget.onExit;
}
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('跳过,直接进入课程')),
],
),
);
}
class _ListeningStep extends StatelessWidget {
const _ListeningStep({
required this.state,
required this.activity,
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 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 = activity.answers;
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 && selectedAnswer != 0)
const Text(
'再听一次,选择正确答案。',
style: TextStyle(color: AppColors.warmInk),
),
],
),
);
}
}
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> {
bool listening = false;
bool transcribing = false;
bool playingRecording = false;
String transcript = '';
String? recordingPath;
@override
void dispose() {
VoiceService.instance.stopRecordingPlayback();
if (!widget.keepRecording) {
VoiceService.instance.deleteRecording(recordingPath);
}
super.dispose();
}
Future<void> _toggleMic() async {
if (listening) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
listening = false;
transcribing = true;
recordingPath = path;
});
if (path != null) {
final config = widget.state.aiConfig;
final text = await AiService.instance.transcribeAudio(
filePath: path,
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
);
if (mounted) {
setState(() {
transcribing = false;
if (text != null && text.trim().isNotEmpty) {
transcript = text.trim();
}
});
if (text == null || text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰发音,请重试或点击“播放示范音”。')),
);
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
return;
}
await VoiceService.instance.stopRecordingPlayback();
if (mounted) setState(() => playingRecording = false);
if (!widget.keepRecording) {
await VoiceService.instance.deleteRecording(recordingPath);
}
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
listening = recordStarted;
if (recordStarted) {
recordingPath = null;
}
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
}
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;
}
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
recordingPath!,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.stopRecordingPlayback();
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) {
setState(() {
recordingPath = null;
playingRecording = false;
});
}
}
@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,
),
],
),
);
}
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();
bool showAnswer = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
bool get isCorrect {
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
'',
);
final response = controller.text.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
'',
);
return response.isNotEmpty && response.contains(answer);
}
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.softGreen,
child: LexiconText(
widget.activity.reading,
state: widget.state,
style: TextStyle(fontSize: 16, height: 1.6),
),
),
Text(widget.activity.readingQuestion),
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined),
label: const Text('查词或短语'),
),
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 && controller.text.isNotEmpty && !isCorrect)
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
),
PrimaryButton(
label: showAnswer ? '继续写一写' : '检查并继续',
onPressed: showAnswer || isCorrect ? widget.onContinue : null,
),
],
),
);
}
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,
),
],
),
);
}
class _DialoguePendingStep extends StatelessWidget {
const _DialoguePendingStep({required this.onOpenDialogue});
final VoidCallback onOpenDialogue;
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 6,
child: SpacedColumn(
children: [
const Eyebrow('课程对话'),
Text('在情境中用上刚学的句子。', style: Theme.of(context).textTheme.headlineMedium),
const Text('完成姓名、地点、一个状态或喜好,并反问对方。'),
PrimaryButton(label: '开始文字对话', onPressed: onOpenDialogue),
],
),
);
}
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> {
bool listening = false;
bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false;
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? recordingPath;
String? validationError;
@override
void dispose() {
VoiceService.instance.stopRecordingPlayback();
if (!widget.keepRecording) {
VoiceService.instance.deleteRecording(recordingPath);
}
super.dispose();
}
void _submit() {
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
return;
}
widget.onContinue(
usedVoice && !transcriptEdited,
widget.keepRecording ? recordingPath : null,
);
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
Future<void> _toggleMic() async {
if (aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
aiVoiceRecording = false;
listening = false;
transcribing = true;
recordingPath = path;
});
if (path != null) {
final config = widget.state.aiConfig;
final text = await AiService.instance.transcribeAudio(
filePath: path,
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
);
if (mounted) {
setState(() {
transcribing = false;
if (text != null && text.trim().isNotEmpty) {
widget.controller.text = text.trim();
usedVoice = true;
lastTranscript = text.trim();
transcriptEdited = false;
}
});
widget.onChanged();
if (text == null || text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到声音,请重试或直接打字输入。')),
);
}
}
} else {
if (mounted) setState(() => transcribing = false);
}
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 VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
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,
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。'
: '可录下这次尝试并回听;离开本页后会自动删除。',
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
],
),
),
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('稍后继续')),
],
),
);
}
class _CompletionStep extends StatelessWidget {
const _CompletionStep({
required this.assisted,
required this.isFinalSegment,
required this.nextSegmentNumber,
required this.onFinish,
});
final bool assisted;
final bool isFinalSegment;
final int nextSegmentNumber;
final VoidCallback onFinish;
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 6,
child: SpacedColumn(
children: [
const Eyebrow('本段已保存'),
Text(
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
PrimaryButton(label: '回到首页', onPressed: onFinish),
],
),
);
}
class _AudioRow extends StatelessWidget {
const _AudioRow({required this.label, this.speech, this.onPlayed});
final String label;
final String? speech;
final VoidCallback? onPlayed;
@override
Widget build(BuildContext context) => Row(
children: [
IconButton.filled(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
icon: const Icon(Icons.play_arrow),
),
const SizedBox(width: 10),
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(label),
),
),
),
TextButton(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
);
}