Files
English/kouyu_english/lib/features/onboarding/onboarding_pages.dart
T
shenleiandClaude Opus 5 9ab08c25ef fix: 修正学习流程中的证据、复习与进度问题
- 独立尝试按课程要求校验内容,不再任意输入即算独立成功
- 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境
- 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助
- 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习
- 复习成功不再降低已有状态
- 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估
- 新增 3 题基础定位,按结果设置起始课程
- 新增按课程开放的对话场景,首页推荐最少练习的场景
- 切换课程时清空上一课的步骤进度
- AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:52:01 +09:00

504 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/review_feedback.dart';
import '../../core/seed_courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key, required this.state, required this.onContinue});
final AppState state;
final VoidCallback onContinue;
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
late int selectedMinutes;
@override
void initState() {
super.initState();
selectedMinutes = widget.state.dailyMinutes;
}
@override
Widget build(BuildContext context) {
return AppPage(
child: SpacedColumn(
spacing: 20,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/branding/logo_512.png',
width: 44,
height: 44,
),
),
const SizedBox(width: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'芽说英语',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.ink,
),
),
Text(
'SpeakSprout',
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const Eyebrow('欢迎'),
Text(
'每天 20 分钟,\n说出能用的英语。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
_ChoiceGroup<int>(
title: '每天学习多久?',
value: selectedMinutes,
options: const {10: '10 分钟', 20: '20 分钟', 30: '30 分钟'},
onChanged: (value) => setState(() => selectedMinutes = value),
),
PrimaryButton(
label: '继续',
onPressed: () {
widget.state.setDailyMinutes(selectedMinutes);
widget.onContinue();
},
),
],
),
);
}
}
class PlacementPage extends StatefulWidget {
const PlacementPage({
super.key,
required this.state,
required this.onStart,
this.onBack,
});
final AppState state;
final VoidCallback onStart;
final VoidCallback? onBack;
@override
State<PlacementPage> createState() => _PlacementPageState();
}
enum _PlacementPhase { selfReport, listen, readAloud, answer, result }
class _PlacementPageState extends State<PlacementPage>
with VoiceAnswerMixin<PlacementPage> {
static const _listeningText = 'Hello. My name is Mia. Nice to meet you.';
static const _listeningOptions = [
'你好,我叫 Mia,很高兴认识你。',
'你好,我来自 Mia,今天很开心。',
'再见,Mia,明天见。',
];
static const _readAloudText = 'Hello. My name is ...';
late PlacementLevel selected;
var phase = _PlacementPhase.selfReport;
int listeningChoice = -1;
final answerController = TextEditingController();
@override
AppState get voiceState => widget.state;
@override
void initState() {
super.initState();
selected = widget.state.placement;
}
@override
void dispose() {
VoiceService.instance.stopSpeaking();
disposeVoiceAnswer(keepRecording: false);
answerController.dispose();
super.dispose();
}
bool get _understoodGreeting => listeningChoice == 0;
bool get _answeredName =>
coreItemUsedIn('A0-P01', answerController.text.trim());
/// Placement only picks an A0 starting point until A1 content is frozen, and
/// it never marks a lesson as passed.
String get _suggestedLessonId =>
_understoodGreeting && _answeredName ? 'a0-02' : 'a0-01';
String get _reason => _understoodGreeting && _answeredName
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
: _understoodGreeting
? '你能听懂问候,先把自我介绍说完整会更稳。'
: '先从问候和介绍名字开始,打好第一句的基础。';
void _back() {
VoiceService.instance.stopSpeaking();
switch (phase) {
case _PlacementPhase.selfReport:
widget.onBack?.call();
case _PlacementPhase.listen:
setState(() => phase = _PlacementPhase.selfReport);
case _PlacementPhase.readAloud:
setState(() => phase = _PlacementPhase.listen);
case _PlacementPhase.answer:
setState(() => phase = _PlacementPhase.readAloud);
case _PlacementPhase.result:
setState(() => phase = _PlacementPhase.answer);
}
}
void _startFrom(String lessonId) {
widget.state.setPlacementStartLesson(lessonId);
widget.onStart();
}
Future<void> _toggleVoiceAnswer() async {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) => answerController.text = text,
);
return;
}
await startVoiceInput();
}
@override
Widget build(BuildContext context) {
final showBack =
widget.onBack != null || phase != _PlacementPhase.selfReport;
return AppPage(
appBar: showBack
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: _back,
),
title: const Text("基础定位"),
)
: null,
child: switch (phase) {
_PlacementPhase.selfReport => _selfReport(context),
_PlacementPhase.listen => _listen(context),
_PlacementPhase.readAloud => _readAloud(context),
_PlacementPhase.answer => _answer(context),
_PlacementPhase.result => _result(context),
},
);
}
Widget _selfReport(BuildContext context) {
const labels = {
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return SpacedColumn(
spacing: 14,
children: [
const Eyebrow('第一步 · 约 3 分钟'),
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
const Text('选择最接近的状态,之后随时能调整。'),
for (final option in PlacementLevel.values)
_PlacementChoice(
option: option,
labels: labels[option]!,
isSelected: selected == option,
onTap: () => setState(() => selected = option),
),
PrimaryButton(
label: '开始 3 分钟定位',
onPressed: () {
widget.state.setPlacement(selected);
setState(() => phase = _PlacementPhase.listen);
},
),
Center(
child: TextButton(
onPressed: () => _startFrom('a0-01'),
child: const Text('直接从第一课开始'),
),
),
],
);
}
Widget _listen(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 1 / 3'),
Text('听一听,选出意思。', style: Theme.of(context).textTheme.headlineMedium),
SecondaryButton(
label: '播放句子',
onPressed: () => VoiceService.instance.speak(_listeningText),
),
for (var index = 0; index < _listeningOptions.length; index++)
SectionCard(
tint: listeningChoice == index ? AppColors.softGreen : null,
onTap: () => setState(() => listeningChoice = index),
child: Row(
children: [
Icon(
listeningChoice == index
? Icons.radio_button_checked
: Icons.radio_button_off,
color: listeningChoice == index
? AppColors.green
: AppColors.muted,
),
const SizedBox(width: 10),
Expanded(child: Text(_listeningOptions[index])),
],
),
),
PrimaryButton(
label: '下一题',
onPressed: listeningChoice < 0
? null
: () => setState(() => phase = _PlacementPhase.readAloud),
),
Center(
child: TextButton(
onPressed: () => setState(() {
listeningChoice = -1;
phase = _PlacementPhase.readAloud;
}),
child: const Text('听不懂,下一题'),
),
),
],
);
Widget _readAloud(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 2 / 3'),
Text('跟着读一句。', style: Theme.of(context).textTheme.headlineMedium),
const Text(
_readAloudText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600),
),
SecondaryButton(
label: '播放示范音',
onPressed: () => VoiceService.instance.speak(_readAloudText),
),
const SectionCard(
tint: AppColors.surfaceMuted,
child: Text('这一题只是开口热身,不录音、不打分,可以跳过。'),
),
PrimaryButton(
label: '我读完了',
onPressed: () => setState(() => phase = _PlacementPhase.answer),
),
Center(
child: TextButton(
onPressed: () => setState(() => phase = _PlacementPhase.answer),
child: const Text('现在不方便开口,跳过'),
),
),
],
);
Widget _answer(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 3 / 3'),
Text('回答这个问题。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
children: [
const Expanded(
child: Text(
'What is your name?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
IconButton(
tooltip: '播放问题',
icon: const Icon(Icons.volume_up_outlined),
onPressed: () =>
VoiceService.instance.speak('What is your name?'),
),
],
),
),
TextField(
controller: answerController,
decoration: const InputDecoration(hintText: '用英语输入你的回答'),
onChanged: (_) => setState(() {}),
),
SecondaryButton(
label: transcribing ? '正在识别…' : (aiVoiceRecording ? '说完了,识别' : '用语音回答'),
onPressed: transcribing ? null : _toggleVoiceAnswer,
),
PrimaryButton(
label: '查看建议',
onPressed: aiVoiceRecording || transcribing
? null
: () => setState(() => phase = _PlacementPhase.result),
),
Center(
child: TextButton(
onPressed: () => setState(() {
answerController.clear();
phase = _PlacementPhase.result;
}),
child: const Text('还不会说,查看建议'),
),
),
],
);
Widget _result(BuildContext context) {
final lesson = lessonById(_suggestedLessonId);
return SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位结果'),
Text(
'建议从 A0 第 ${lesson.number} 课开始',
style: Theme.of(context).textTheme.headlineMedium,
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Text(
'第 ${lesson.number} 课 · ${lesson.title}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(_reason),
],
),
),
const Text('前面的课程随时可以回去学,定位不会跳过任何复习。'),
PrimaryButton(
label: '开始今天学习',
onPressed: () => _startFrom(_suggestedLessonId),
),
if (_suggestedLessonId != 'a0-01')
Center(
child: TextButton(
onPressed: () => _startFrom('a0-01'),
child: const Text('从更简单内容开始'),
),
),
],
);
}
}
class _PlacementChoice extends StatelessWidget {
const _PlacementChoice({
required this.option,
required this.labels,
required this.isSelected,
required this.onTap,
});
final PlacementLevel option;
final (String, String) labels;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => SectionCard(
tint: isSelected ? AppColors.softGreen : null,
onTap: onTap,
child: Row(
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
color: isSelected ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
labels.$1,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(labels.$2, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class _ChoiceGroup<T> extends StatelessWidget {
const _ChoiceGroup({
required this.title,
required this.value,
required this.options,
required this.onChanged,
});
final String title;
final T value;
final Map<T, String> options;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SpacedColumn(
spacing: 10,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.entries
.map(
(entry) => ChoiceChip(
label: Text(entry.value),
selected: value == entry.key,
selectedColor: AppColors.softGreen,
onSelected: (_) => onChanged(entry.key),
),
)
.toList(),
),
],
),
);
}
}