import 'package:flutter/material.dart'; import '../../core/app_state.dart'; import '../../core/app_theme.dart'; import '../../core/courses/courses.dart'; import '../../core/models.dart'; import '../../core/voice_service.dart'; import '../../widgets/app_widgets.dart'; /// What the round of recognition is about right now. The home card and this /// page share it so the two never describe today's words differently. String wordTaskHeadline(AppState state) { final due = state.dueWords.length; if (due > 0) return '今天有 $due 个词该过一遍'; final unasked = state.unaskedWords.length; if (unasked > 0) return '有 $unasked 个新学的词还没问过'; return '今天还能学 ${state.newWordQuota} 个新词'; } /// The word list the learner can open on its own (learning engine 3.5). /// /// Words are never asked to be produced here: the only task is a 5–10 second /// recognition question, and the rest of the page is just what has been met. class WordsPage extends StatefulWidget { const WordsPage({super.key, required this.state}); final AppState state; @override State createState() => _WordsPageState(); } class _WordsPageState extends State { List? quiz; int index = 0; String? picked; int right = 0; void _startQuiz() { final plan = widget.state.wordSession(); if (plan.isEmpty) return; setState(() { quiz = plan; index = 0; picked = null; right = 0; }); _playIfHeard(plan.first); } /// A listening question plays itself: the learner should not have to find a /// button before the question has even been asked. void _playIfHeard(WordQuestion question) { if (question.mode != WordAskMode.listen) return; VoiceService.instance.speak(wordEnglish(question.id)); } void _answer(WordQuestion question, String option) { final correct = option == question.answer; widget.state.recordWordRecognition(question.id, correct: correct); setState(() { picked = option; if (correct) right += 1; }); } void _next() { final last = index + 1 >= quiz!.length; setState(() { if (last) { quiz = null; } else { index += 1; } picked = null; }); if (!last) _playIfHeard(quiz![index]); } @override Widget build(BuildContext context) { if (quiz case final questions?) return _quizView(questions[index]); return _listView(); } Widget _quizView(WordQuestion question) { final answered = picked != null; final english = wordEnglish(question.id); final ipa = wordIpa(question.id); // Before the answer each way round gives a different clue; after it all // three land on the spelling, which is what the question was for. // Withholding it earlier is the question: a heard word shown spelled is // a reading question, and a meaning shown next to its word is no question // at all. final prompt = switch (question.mode) { WordAskMode.read => english, WordAskMode.listen => answered ? english : '· · ·', WordAskMode.recall => answered ? english : question.shown, }; // Speaking the word before a recall answer would just read it out. final canSpeak = answered || question.mode != WordAskMode.recall; return AppPage( child: SpacedColumn( children: [ Eyebrow( '${switch (question.mode) { WordAskMode.read => '认词', WordAskMode.listen => '听词', WordAskMode.recall => '想词', }} · ${index + 1} / ${quiz!.length}', ), Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( prompt, style: Theme.of(context).textTheme.headlineMedium, ), if (answered && ipa.isNotEmpty && question.mode != WordAskMode.read) Text(ipa, style: Theme.of(context).textTheme.bodyMedium), ], ), ), if (canSpeak) IconButton( icon: Icon( question.mode == WordAskMode.listen ? Icons.replay_outlined : Icons.volume_up_outlined, ), tooltip: question.mode == WordAskMode.listen ? '再听一遍' : null, onPressed: () => VoiceService.instance.speak(english), ), ], ), Text(switch (question.mode) { WordAskMode.read => '它是什么意思?只要认出来就行,不用会说。', WordAskMode.listen => '听到的是哪个意思?听不清可以再听一遍。', WordAskMode.recall => '哪个词是这个意思?认出来就行,不用自己拼。', }), for (final option in question.options) SizedBox( width: double.infinity, child: OutlinedButton( onPressed: answered ? null : () => _answer(question, option), style: !answered || option != question.answer ? null : OutlinedButton.styleFrom( backgroundColor: AppColors.softGreen, ), child: Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Text(option, style: const TextStyle(fontSize: 16)), ), ), ), ), if (answered) ...[ SectionCard( tint: picked == question.answer ? AppColors.softGreen : AppColors.warm, child: SpacedColumn( children: [ Text( picked == question.answer ? '对了。' : question.mode == WordAskMode.recall ? '「${question.shown}」是 $english,明天再问你一次。' : '$english 是「${question.answer}」,明天再问你一次。', style: TextStyle(color: AppColors.warmInk, height: 1.45), ), // The sentence is the point of the feedback: a gloss alone is // what did not stick the first time. if (WordBank.instance.exampleOf(question.id) case final sample?) ...[ Text( sample.en, style: TextStyle( color: AppColors.warmInk, fontSize: 16, height: 1.45, ), ), if (sample.zh.isNotEmpty) Text( sample.zh, style: Theme.of(context).textTheme.bodyMedium, ), ], if (WordBank.instance.moreSenses(question.id) case final more when more.isNotEmpty) Text( '也有「$more」的意思。', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), PrimaryButton( label: index + 1 >= quiz!.length ? '做完了' : '下一个', onPressed: _next, ), ], TextButton( onPressed: () => setState(() { quiz = null; picked = null; }), child: const Text('先不做了'), ), ], ), ); } Widget _listView() { final state = widget.state; final shaky = state.shakyWords; final grouped = state.wordsByUnit; final waiting = state.unstartedBankWordCount; final quota = state.newWordQuota; final planned = state.wordSessionPreviewSize; return AppPage( child: SpacedColumn( children: [ const Eyebrow('学过的词'), Text( grouped.isEmpty ? '还没有学过的词' : '认识 ${state.recognizedWordCount} · 熟悉 ${state.familiarWordCount}', style: Theme.of(context).textTheme.headlineMedium, ), const Text('这些词只要听到、看到能认出来就够了,不用背着写出来。'), if (planned > 0) SectionCard( tint: AppColors.softGreen, child: SpacedColumn( children: [ Text( wordTaskHeadline(state), style: const TextStyle(fontSize: 17), ), Text( '$planned 个题,一个题几秒钟。', style: Theme.of(context).textTheme.bodyMedium, ), PrimaryButton(label: '开始认词', onPressed: _startQuiz), ], ), ) else if (grouped.isNotEmpty) SectionCard( tint: AppColors.surfaceMuted, child: Text( quota > 0 ? '今天的词都过完了,剩下的等到期再问。' : '今天的新词学满 ${state.newWordsPerDay} 个了,到期的也都过完了。' '明天它们会回来找你。', ), ), if (waiting > 0) Text( quota > 0 ? '词库里还有 $waiting 个词没开始,今天还能学 $quota 个。' : '词库里还有 $waiting 个词没开始,明天继续。', style: Theme.of(context).textTheme.bodySmall, ), if (shaky.isNotEmpty) ...[ const Eyebrow('没记住的'), SectionCard( tint: AppColors.warm, child: SpacedColumn( children: [ for (final id in shaky.take(8)) _WordRow(id: id, state: state), ], ), ), ], for (final entry in grouped.entries) ...[ Eyebrow(_unitTitle(entry.key)), SectionCard( tint: AppColors.surfaceMuted, child: SpacedColumn( children: [ for (final id in entry.value.take(_groupLimit)) _WordRow(id: id, state: state), if (entry.value.length > _groupLimit) Text( '还有 ${entry.value.length - _groupLimit} 个', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ], ], ), ); } /// A unit group is a reminder of what has been met, not a dictionary, so it /// stops before the bank turns it into an endless scroll. static const _groupLimit = 30; String _unitTitle(String unit) { if (unit.isEmpty) return '查词收藏'; if (WordBank.isBankUnit(unit)) return '词库 · ${WordBank.levelOfUnit(unit)}'; final lesson = allLessons.where((item) => item.id == unit); return lesson.isEmpty ? unit : lesson.first.title; } } class _WordRow extends StatelessWidget { const _WordRow({required this.id, required this.state}); final String id; final AppState state; @override Widget build(BuildContext context) { final status = state.wordStatus(id); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( wordEnglish(id), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), Text( wordIpa(id).isEmpty ? wordMeaning(id) : '${wordIpa(id)} ${wordMeaning(id)}', style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), Text( switch (status) { WordStatus.newWord => '新学', WordStatus.recognized => '认识', WordStatus.familiar => '熟悉', }, style: Theme.of(context).textTheme.bodySmall, ), IconButton( icon: const Icon(Icons.volume_up_outlined, size: 20), onPressed: () => VoiceService.instance.speak(wordEnglish(id)), ), ], ); } }