feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/ai_service.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
|
||||
class DialogueScenePage extends StatelessWidget {
|
||||
const DialogueScenePage({super.key, required this.onStart});
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('按当前水平推荐'),
|
||||
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(
|
||||
'每次不超过 5 个回答轮,完成明确任务后结束。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'初次见面',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text('A0', style: TextStyle(color: AppColors.green)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'介绍姓名、地点、状态或喜好,并反问对方',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
PrimaryButton(label: '开始对话', onPressed: onStart),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
|
||||
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LockedScene extends StatelessWidget {
|
||||
const _LockedScene({required this.title, required this.note});
|
||||
final String title;
|
||||
final String note;
|
||||
@override
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(note, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class DialoguePage extends StatefulWidget {
|
||||
const DialoguePage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onFinished,
|
||||
this.isLessonDialogue = false,
|
||||
});
|
||||
final AppState state;
|
||||
final ValueChanged<DialogueSummaryData?> onFinished;
|
||||
final bool isLessonDialogue;
|
||||
@override
|
||||
State<DialoguePage> createState() => _DialoguePageState();
|
||||
}
|
||||
|
||||
class _DialoguePageState extends State<DialoguePage> {
|
||||
final controller = TextEditingController();
|
||||
final List<DialogueTurn> turns = [];
|
||||
int stage = 0;
|
||||
bool usedHelp = false;
|
||||
String? hint;
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
String? recordingPath;
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
|
||||
LessonDialogue get script => widget.isLessonDialogue
|
||||
? dialogueBySegmentId(
|
||||
lessonById(widget.state.activeLessonId)
|
||||
.segments[widget.state.activeSegmentIndexFor(
|
||||
widget.state.activeLessonId,
|
||||
)]
|
||||
.id,
|
||||
widget.state.activeLessonId,
|
||||
)
|
||||
: const LessonDialogue(
|
||||
goal: '姓名、地点、状态或喜好,并反问',
|
||||
prompts: prompts,
|
||||
hints: hints,
|
||||
);
|
||||
|
||||
static const prompts = [
|
||||
'Hi! My name is Mia. What’s your name?',
|
||||
'Nice to meet you. Where are you from?',
|
||||
'Great! How are you today? Or what do you like?',
|
||||
'I’m good, thanks. Now ask me one question!',
|
||||
];
|
||||
static const hints = [
|
||||
'My name is Alex.',
|
||||
'I’m from Hong Kong.',
|
||||
'I’m good, thanks. / I like coffee.',
|
||||
'What’s your name? / Where are you from?',
|
||||
];
|
||||
static const translations = [
|
||||
'嗨!我叫 Mia。你叫什么名字?',
|
||||
'很高兴认识你。你来自哪里?',
|
||||
'很好!你今天怎么样?或者你喜欢什么?',
|
||||
'我很好,谢谢。现在请问我一个问题!',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final draft = widget.state.dialogueDraft;
|
||||
final canRestore =
|
||||
widget.isLessonDialogue &&
|
||||
draft?.lessonId == widget.state.activeLessonId &&
|
||||
draft!.stage >= 0 &&
|
||||
draft.stage <= script.prompts.length &&
|
||||
draft.turns.isNotEmpty;
|
||||
if (canRestore) {
|
||||
stage = draft.stage;
|
||||
usedHelp = draft.usedHelp;
|
||||
turns.addAll(draft.turns);
|
||||
} else {
|
||||
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> send() async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
|
||||
return;
|
||||
}
|
||||
if (!_matchesCurrentTask(text)) {
|
||||
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
|
||||
return;
|
||||
}
|
||||
widget.state.recordDialogueAttempt(
|
||||
taskId:
|
||||
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
|
||||
rawAnswer: text,
|
||||
assisted: usedHelp,
|
||||
spoken: usedVoice && !transcriptEdited,
|
||||
recordingPath: widget.state.keepRecordings ? recordingPath : null,
|
||||
);
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
recordingPath = null;
|
||||
}
|
||||
final nextStage = stage + 1;
|
||||
setState(() {
|
||||
turns.add(DialogueTurn(text: text, isLearner: true));
|
||||
controller.clear();
|
||||
stage = nextStage;
|
||||
waitingForReply = true;
|
||||
hint = null;
|
||||
validationError = null;
|
||||
});
|
||||
_saveDraft();
|
||||
final aiResponse = await AiService.instance.dialogueReply(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
requiredTask: nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Say goodbye warmly after the learner asked a question.',
|
||||
history: turns
|
||||
.map(
|
||||
(turn) => <String, String>{
|
||||
'role': turn.isLearner ? 'user' : 'assistant',
|
||||
'content': turn.text,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text:
|
||||
aiResponse?.reply ??
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Wonderful — nice meeting you!'),
|
||||
isLearner: false,
|
||||
),
|
||||
);
|
||||
waitingForReply = false;
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
void _saveDraft() {
|
||||
if (!widget.isLessonDialogue) return;
|
||||
widget.state.saveDialogueDraft(
|
||||
DialogueDraft(
|
||||
lessonId: widget.state.activeLessonId,
|
||||
stage: stage,
|
||||
turns: List.unmodifiable(turns),
|
||||
usedHelp: usedHelp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesCurrentTask(String response) {
|
||||
if (!widget.isLessonDialogue) {
|
||||
final text = response.toLowerCase();
|
||||
return switch (stage) {
|
||||
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
|
||||
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
|
||||
2 => RegExp(
|
||||
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
|
||||
).hasMatch(text),
|
||||
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
|
||||
};
|
||||
}
|
||||
final text = response.toLowerCase();
|
||||
final lessonId = widget.state.activeLessonId;
|
||||
if (lessonById(lessonId).segments.length > 1) {
|
||||
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
|
||||
}
|
||||
if (stage == 0) {
|
||||
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
|
||||
}
|
||||
if (lessonId == 'a0-02' && stage == 1) {
|
||||
return RegExp(
|
||||
r'[a-z](?:[ -]?[a-z]){2,}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(text);
|
||||
}
|
||||
final expected = switch (lessonId) {
|
||||
'a0-01' => ['nice', 'hello', 'what'],
|
||||
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
|
||||
'a0-04' => [
|
||||
'yes',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'zero',
|
||||
'what',
|
||||
],
|
||||
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
|
||||
'a0-06' => ['from', 'where', 'bye'],
|
||||
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
|
||||
'a0-08' => [
|
||||
'it',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
'clock',
|
||||
'what',
|
||||
],
|
||||
'a0-09' => ['like', 'yes', 'no', 'do'],
|
||||
'a0-10' => ['please', 'what', 'like'],
|
||||
_ => ['nice', 'how', 'what', 'from', 'like'],
|
||||
};
|
||||
return expected.any(text.contains);
|
||||
}
|
||||
|
||||
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
|
||||
.segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)]
|
||||
.id;
|
||||
|
||||
void _finish() {
|
||||
if (widget.isLessonDialogue) {
|
||||
widget.state.clearDialogueDraft();
|
||||
widget.state.completeLessonDialogue();
|
||||
widget.onFinished(null);
|
||||
return;
|
||||
}
|
||||
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
|
||||
final personalSentence = learnerTurns.isEmpty
|
||||
? 'My name is …'
|
||||
: learnerTurns.first.text;
|
||||
widget.state.addDialogueRecap(personalSentence);
|
||||
widget.onFinished(
|
||||
DialogueSummaryData(
|
||||
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
|
||||
personalSentence: personalSentence,
|
||||
usedHelp: usedHelp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playLatestAi({required bool slow}) async {
|
||||
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
|
||||
if (latest == null) return;
|
||||
await VoiceService.instance.speak(latest.text, slow: slow);
|
||||
if (!slow || !mounted) return;
|
||||
setState(() => usedHelp = true);
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final available = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => listening = available);
|
||||
if (!available) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
if (recording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recording = false;
|
||||
recordingPath = path;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
final ready = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
recording = ready;
|
||||
if (ready) recordingPath = null;
|
||||
});
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playRecording() async {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
}
|
||||
|
||||
void _showWord() => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finished = stage == script.prompts.length;
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
|
||||
),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('目标:${script.goal}'),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final turn = turns[index];
|
||||
return Align(
|
||||
alignment: turn.isLearner
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: turn.isLearner
|
||||
? AppColors.warm
|
||||
: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: LexiconText(turn.text, state: widget.state),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!finished) ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_AssistChip(
|
||||
label: '提示',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = script.hints[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '翻译',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = translations[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '慢一点',
|
||||
onTap: () => _playLatestAi(slow: true),
|
||||
),
|
||||
_AssistChip(
|
||||
label: '重说',
|
||||
onTap: () => _playLatestAi(slow: false),
|
||||
),
|
||||
_AssistChip(label: '查词', onTap: _showWord),
|
||||
],
|
||||
),
|
||||
if (hint != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
hint!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (validationError != null)
|
||||
Text(
|
||||
validationError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {
|
||||
if (usedVoice && value != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
}
|
||||
}),
|
||||
onSubmitted: (_) => send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你的英文回答',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止录音' : '语音输入',
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
onPressed: _toggleListening,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: waitingForReply ? null : send,
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (usedVoice)
|
||||
Text(
|
||||
transcriptEdited
|
||||
? '你修改了设备转写:这轮按文字作答保存。'
|
||||
: '这是设备转写;未修改提交后会作为语音尝试保存。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _toggleRecording,
|
||||
icon: Icon(
|
||||
recording
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.fiber_manual_record,
|
||||
),
|
||||
label: Text(recording ? '停止录音' : '录音回听'),
|
||||
),
|
||||
),
|
||||
if (recordingPath != null) ...[
|
||||
IconButton(
|
||||
tooltip: playingRecording ? '正在播放' : '回听录音',
|
||||
onPressed: playingRecording ? null : _playRecording,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: _deleteRecording,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.state.keepRecordings
|
||||
? '录音只保存在本机,不会发送给 AI。'
|
||||
: '录音仅供本次回听,离开后自动删除。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
if (waitingForReply) const LinearProgressIndicator(),
|
||||
] else ...[
|
||||
SectionCard(
|
||||
tint: usedHelp ? AppColors.warm : AppColors.softGreen,
|
||||
child: Text(
|
||||
usedHelp
|
||||
? '本次使用过提示,课程会把关键表达安排到后续复习。'
|
||||
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
|
||||
onPressed: _finish,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistChip extends StatelessWidget {
|
||||
const _AssistChip({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) => ActionChip(
|
||||
label: Text(label),
|
||||
backgroundColor: AppColors.surface,
|
||||
side: const BorderSide(color: AppColors.line),
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
class DialogueSummaryPage extends StatelessWidget {
|
||||
const DialogueSummaryPage({
|
||||
super.key,
|
||||
required this.summary,
|
||||
required this.onHome,
|
||||
required this.onLesson,
|
||||
required this.onRetry,
|
||||
});
|
||||
final DialogueSummaryData summary;
|
||||
final VoidCallback onHome;
|
||||
final VoidCallback onLesson;
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('对话完成'),
|
||||
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text('你完成了 ${summary.completedTasks.join('、')}。'),
|
||||
SectionCard(
|
||||
child: _SummaryLine(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: '你的个人复习卡(明天出现)',
|
||||
sentence: summary.personalSentence,
|
||||
tint: AppColors.green,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
summary.usedHelp
|
||||
? '本次使用过提示。下次可以先不看提示,再试一次。'
|
||||
: '下次试着换一个名字、地点或喜好,再完成同一任务。',
|
||||
),
|
||||
SecondaryButton(label: '再练一次初次见面', onPressed: onRetry),
|
||||
PrimaryButton(label: '开始一节课程', onPressed: onLesson),
|
||||
SecondaryButton(label: '回到首页', onPressed: onHome),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SummaryLine extends StatelessWidget {
|
||||
const _SummaryLine({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.sentence,
|
||||
required this.tint,
|
||||
});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String sentence;
|
||||
final Color tint;
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: tint),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: tint, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(sentence),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user