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