1343 lines
41 KiB
Dart
1343 lines
41 KiB
Dart
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 recording = false;
|
||
bool transcribing = false;
|
||
bool aiVoiceRecording = 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 (aiVoiceRecording) {
|
||
final path = await VoiceService.instance.stopRecording();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
aiVoiceRecording = false;
|
||
listening = false;
|
||
transcribing = true;
|
||
});
|
||
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;
|
||
}
|
||
|
||
if (listening) {
|
||
await VoiceService.instance.stopListening();
|
||
if (mounted) setState(() => listening = false);
|
||
return;
|
||
}
|
||
|
||
final ready = await VoiceService.instance.startListening(
|
||
(text, _) {
|
||
if (mounted) setState(() => transcript = text);
|
||
},
|
||
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);
|
||
}
|
||
|
||
Future<void> _toggleRecording() async {
|
||
if (recording) {
|
||
final path = await VoiceService.instance.stopRecording();
|
||
if (mounted) {
|
||
setState(() {
|
||
recording = false;
|
||
recordingPath = path;
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
final ready = await VoiceService.instance.startRecording();
|
||
if (!mounted) return;
|
||
setState(() => recording = ready);
|
||
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);
|
||
if (mounted) setState(() => playingRecording = false);
|
||
}
|
||
|
||
Future<void> _deleteRecording() async {
|
||
await VoiceService.instance.deleteRecording(recordingPath);
|
||
if (mounted) setState(() => recordingPath = null);
|
||
}
|
||
|
||
@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: recording ? '停止本机录音' : '录音后回听',
|
||
onPressed: listening ? null : _toggleRecording,
|
||
),
|
||
if (recordingPath != null)
|
||
SectionCard(
|
||
tint: AppColors.softGreen,
|
||
child: SpacedColumn(
|
||
spacing: 8,
|
||
children: [
|
||
Text(widget.keepRecording ? '录音已保存在本机。' : '本次录音仅在离开此步骤前保留。'),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton.icon(
|
||
onPressed: playingRecording ? null : _playRecording,
|
||
icon: const Icon(Icons.play_arrow),
|
||
label: const Text('回听'),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton(
|
||
tooltip: '删除录音',
|
||
onPressed: _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);
|
||
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;
|
||
});
|
||
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('慢速'),
|
||
),
|
||
],
|
||
);
|
||
}
|