part of '../lesson_flow.dart'; /// The unit's listening/reading comprehension step. It presents each material's /// text (with word lookup and glosses) and a few multiple-choice questions. The /// learner continues once every question is answered correctly or its answer /// has been revealed — comprehension is checked, but it never blocks the lesson. class _MaterialStep extends StatefulWidget { const _MaterialStep({ required this.state, required this.materials, required this.onContinue, }); final AppState state; final List materials; final VoidCallback onContinue; @override State<_MaterialStep> createState() => _MaterialStepState(); } class _MaterialStepState extends State<_MaterialStep> { /// materialIndex -> questionIndex -> selected option index. final Map> _selected = {}; /// materialIndex -> questionIndex where the answer was revealed. final Set _revealed = {}; List _questions(int materialIndex) => widget.materials[materialIndex].questions; List _options(int materialIndex, int questionIndex) { final q = _questions(materialIndex)[questionIndex]; return shuffledOptions(q.options, '${q.question}-material'); } bool _isCorrect(int m, int q, int optionIndex) { final options = _options(m, q); if (optionIndex < 0 || optionIndex >= options.length) return false; return options[optionIndex] == _questions(m)[q].answer; } bool _answered(int m, int q) { if (_revealed.contains('$m-$q')) return true; final chosen = _selected[m]?[q]; return chosen != null && _isCorrect(m, q, chosen); } bool get _allAnswered { for (var m = 0; m < widget.materials.length; m++) { for (var q = 0; q < _questions(m).length; q++) { if (!_answered(m, q)) return false; } } return true; } @override Widget build(BuildContext context) { return _LessonScaffold( step: 4, child: SpacedColumn( children: [ const Eyebrow('听读材料'), Text( '读一段真实的小材料,做完理解题。', style: Theme.of(context).textTheme.headlineMedium, ), for (var m = 0; m < widget.materials.length; m++) _materialCard(context, m), PrimaryButton( label: _allAnswered ? '继续写一写' : '答对所有理解题后继续', onPressed: _allAnswered ? widget.onContinue : null, ), ], ), ); } Widget _materialCard(BuildContext context, int m) { final material = widget.materials[m]; final isListening = material.mode == 'listening'; return SpacedColumn( children: [ SectionCard( tint: AppColors.softGreen, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Icon( isListening ? Icons.headphones_outlined : Icons.menu_book_outlined, size: 16, color: AppColors.green, ), const SizedBox(width: 6), Text( isListening ? '听力材料' : '阅读材料', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.green, ), ), ], ), InkWell( onTap: () => VoiceService.instance.speak(material.text), borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), child: Row( children: [ Icon( Icons.volume_up_outlined, size: 16, color: AppColors.green, ), const SizedBox(width: 4), Text( '朗读', style: TextStyle( fontSize: 13, color: AppColors.green, ), ), ], ), ), ), ], ), if (material.title.isNotEmpty) ...[ const SizedBox(height: 8), Text( material.title, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), ], const SizedBox(height: 8), // A listening material stays hidden until played, so the learner // listens instead of reading it. Reading materials show the text. if (isListening) _ListeningRevealText(state: widget.state, text: material.text) else LexiconText( material.text, state: widget.state, style: const TextStyle(fontSize: 16, height: 1.6), ), if (material.glosses.isNotEmpty) ...[ const SizedBox(height: 12), _GlossList(glosses: material.glosses), ], ], ), ), for (var q = 0; q < material.questions.length; q++) _questionBlock(context, m, q), ], ); } Widget _questionBlock(BuildContext context, int m, int q) { final question = _questions(m)[q]; final options = _options(m, q); final chosen = _selected[m]?[q]; final revealed = _revealed.contains('$m-$q'); return SpacedColumn( children: [ SectionCard( tint: AppColors.surfaceMuted, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.green.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6), ), child: Text( '问题', style: TextStyle( color: AppColors.green, fontWeight: FontWeight.bold, fontSize: 12, ), ), ), const SizedBox(width: 10), Expanded( child: Text( question.question, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.ink, ), ), ), ], ), ), for (var index = 0; index < options.length; index++) SectionCard( tint: chosen == index ? (_isCorrect(m, q, index) ? AppColors.softGreen : AppColors.warm) : null, onTap: () => setState(() { (_selected[m] ??= {})[q] = index; }), child: Row( children: [ Icon( chosen == index ? (_isCorrect(m, q, index) ? Icons.check_circle : Icons.cancel_outlined) : Icons.radio_button_off, color: chosen == index ? (_isCorrect(m, q, index) ? AppColors.green : AppColors.warmInk) : AppColors.muted, ), const SizedBox(width: 12), Expanded( child: Text( options[index], style: TextStyle( fontSize: 15, fontWeight: chosen == index ? FontWeight.w600 : FontWeight.normal, color: chosen == index ? (_isCorrect(m, q, index) ? AppColors.green : AppColors.warmInk) : AppColors.ink, ), ), ), ], ), ), if (revealed) SectionCard( tint: AppColors.warm, child: Text( '答案:${question.answer}', style: TextStyle(color: AppColors.warmInk), ), ) else if (chosen != null && !_isCorrect(m, q, chosen)) TextButton( onPressed: () => setState(() => _revealed.add('$m-$q')), child: const Text('查看答案后继续'), ), ], ); } } /// Keeps a listening material's transcript hidden until it has been played, so /// the learner answers from listening rather than reading. class _ListeningRevealText extends StatefulWidget { const _ListeningRevealText({required this.state, required this.text}); final AppState state; final String text; @override State<_ListeningRevealText> createState() => _ListeningRevealTextState(); } class _ListeningRevealTextState extends State<_ListeningRevealText> { bool _shown = false; @override Widget build(BuildContext context) { if (_shown) { return LexiconText( widget.text, state: widget.state, style: const TextStyle(fontSize: 16, height: 1.6), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('先点“朗读”听两遍,再作答。', style: TextStyle(color: AppColors.muted)), const SizedBox(height: 6), TextButton.icon( onPressed: () => setState(() => _shown = true), icon: const Icon(Icons.visibility_outlined, size: 18), label: const Text('显示原文'), ), ], ); } } /// The material's untaught words with their Chinese glosses. class _GlossList extends StatelessWidget { const _GlossList({required this.glosses}); final Map glosses; @override Widget build(BuildContext context) => Wrap( spacing: 8, runSpacing: 6, children: [ for (final entry in glosses.entries) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.line), ), child: Text( '${entry.key} · ${entry.value}', style: const TextStyle(fontSize: 13), ), ), ], ); }