import 'package:flutter/material.dart'; import '../../core/app_state.dart'; import '../../core/app_theme.dart'; import '../../core/models.dart'; import '../../core/courses/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 createState() => _WelcomePageState(); } class _WelcomePageState extends State { 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), 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( 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 createState() => _PlacementPageState(); } enum _PlacementPhase { selfReport, listen, readAloud, answer, result } class _PlacementPageState extends State with VoiceAnswerMixin { 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 _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), ), 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 extends StatelessWidget { const _ChoiceGroup({ required this.title, required this.value, required this.options, required this.onChanged, }); final String title; final T value; final Map options; final ValueChanged 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(), ), ], ), ); } }