Files
English/kouyu_english/lib/features/words/words_page.dart
T

366 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<WordsPage> createState() => _WordsPageState();
}
class _WordsPageState extends State<WordsPage> {
List<WordQuestion>? 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)),
),
],
);
}
}