feat: A1 短材料复现本单元理解词,并加入黑夜模式
课程内容 - 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本, 单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。 - 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。 - 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、 选项子串等校验全部通过;course_content_test 通过。 黑夜模式 - app_theme 拆分明/暗两套调色板,AppColors 随亮度切换; main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度; 主题偏好持久化到快照;进度页新增“外观主题”切换。 文档 - COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9ab08c25ef
commit
febfd30f49
@@ -178,17 +178,98 @@ CoreReviewTemplate coreReviewTemplate(String id) {
|
||||
}
|
||||
|
||||
/// Rotates an approved offline prompt family without changing the core target
|
||||
/// ID. These are a safe fallback when a dynamic AI variant is unavailable.
|
||||
/// ID. The rotation changes the skill, not just the wording: recall in
|
||||
/// writing, dictation from audio, then saying it aloud.
|
||||
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
|
||||
final base = coreReviewTemplate(id);
|
||||
final lead = switch (variantIndex % 3) {
|
||||
0 => '换一个人物或地点,',
|
||||
1 => '在新的生活情境中,',
|
||||
_ => '不看上次答案,',
|
||||
final dictation = a0DictationSentences[id];
|
||||
return switch (variantIndex % 3) {
|
||||
1 when dictation != null => CoreReviewTemplate(
|
||||
prompt: '听一句话,把听到的整句英文写下来。',
|
||||
hint: dictation.meaning,
|
||||
skill: dictationSkill,
|
||||
),
|
||||
2 => CoreReviewTemplate(
|
||||
prompt: '用英文说出来:${base.prompt}',
|
||||
hint: base.hint,
|
||||
skill: spokenRecallSkill,
|
||||
),
|
||||
_ => base,
|
||||
};
|
||||
return CoreReviewTemplate(
|
||||
prompt: '$lead${base.prompt}',
|
||||
hint: base.hint,
|
||||
skill: base.skill,
|
||||
);
|
||||
}
|
||||
|
||||
const dictationSkill = '听写';
|
||||
const spokenRecallSkill = '口头回忆';
|
||||
|
||||
/// A concrete sentence built only from A0 language, used to play a core item
|
||||
/// in context and to check dictation.
|
||||
typedef DictationSentence = ({String sentence, String meaning});
|
||||
|
||||
const Map<String, DictationSentence> a0DictationSentences = {
|
||||
'A0-W01': (sentence: 'Zero, one, two.', meaning: '零、一、二。'),
|
||||
'A0-W02': (sentence: 'My number is one-two-three.', meaning: '我的号码是一二三。'),
|
||||
'A0-W03': (sentence: 'It’s two o’clock.', meaning: '现在两点。'),
|
||||
'A0-W04': (sentence: 'It’s three o’clock.', meaning: '现在三点。'),
|
||||
'A0-W05': (sentence: 'It’s four o’clock.', meaning: '现在四点。'),
|
||||
'A0-W06': (sentence: 'It’s five o’clock.', meaning: '现在五点。'),
|
||||
'A0-W07': (sentence: 'It’s six o’clock.', meaning: '现在六点。'),
|
||||
'A0-W08': (sentence: 'It’s seven o’clock.', meaning: '现在七点。'),
|
||||
'A0-W09': (sentence: 'It’s eight o’clock.', meaning: '现在八点。'),
|
||||
'A0-W10': (sentence: 'It’s nine o’clock.', meaning: '现在九点。'),
|
||||
'A0-W11': (sentence: 'It’s ten o’clock.', meaning: '现在十点。'),
|
||||
'A0-W12': (sentence: 'It’s Monday.', meaning: '今天星期一。'),
|
||||
'A0-W13': (sentence: 'It’s Tuesday.', meaning: '今天星期二。'),
|
||||
'A0-W14': (sentence: 'It’s Wednesday.', meaning: '今天星期三。'),
|
||||
'A0-W15': (sentence: 'It’s Thursday.', meaning: '今天星期四。'),
|
||||
'A0-W16': (sentence: 'It’s Friday.', meaning: '今天星期五。'),
|
||||
'A0-W17': (sentence: 'It’s Saturday.', meaning: '今天星期六。'),
|
||||
'A0-W18': (sentence: 'It’s Sunday.', meaning: '今天星期日。'),
|
||||
'A0-W19': (sentence: 'It’s a book.', meaning: '这是一本书。'),
|
||||
'A0-W20': (sentence: 'It’s a phone.', meaning: '这是一部电话。'),
|
||||
'A0-W21': (sentence: 'It’s a pen.', meaning: '这是一支笔。'),
|
||||
'A0-W22': (sentence: 'It’s a bag.', meaning: '这是一个包。'),
|
||||
'A0-W23': (sentence: 'It’s a key.', meaning: '这是一把钥匙。'),
|
||||
'A0-W24': (sentence: 'I like water.', meaning: '我喜欢喝水。'),
|
||||
'A0-W25': (sentence: 'This is my mother.', meaning: '这是我妈妈。'),
|
||||
'A0-W26': (sentence: 'This is my father.', meaning: '这是我爸爸。'),
|
||||
'A0-W27': (sentence: 'This is my sister.', meaning: '这是我的姐妹。'),
|
||||
'A0-W28': (sentence: 'This is my brother.', meaning: '这是我的兄弟。'),
|
||||
'A0-W29': (sentence: 'I like coffee.', meaning: '我喜欢咖啡。'),
|
||||
'A0-W30': (sentence: 'Do you like tea?', meaning: '你喜欢茶吗?'),
|
||||
'A0-W31': (sentence: 'I like music.', meaning: '我喜欢音乐。'),
|
||||
'A0-W32': (sentence: 'Do you like movies?', meaning: '你喜欢电影吗?'),
|
||||
'A0-W33': (sentence: 'I’m good.', meaning: '我很好。'),
|
||||
'A0-W34': (sentence: 'I’m okay.', meaning: '我还可以。'),
|
||||
'A0-W35': (sentence: 'I’m tired.', meaning: '我累了。'),
|
||||
'A0-W36': (sentence: 'Hello, how are you?', meaning: '你好,你怎么样?'),
|
||||
'A0-W37': (sentence: 'Hi, what’s your name?', meaning: '嗨,你叫什么名字?'),
|
||||
'A0-W38': (sentence: 'Yes, I do.', meaning: '是的,我喜欢。'),
|
||||
'A0-W39': (sentence: 'No, I don’t.', meaning: '不,我不喜欢。'),
|
||||
'A0-W40': (sentence: 'I’m okay, thanks.', meaning: '我还可以,谢谢。'),
|
||||
'A0-P01': (sentence: 'I’m Tom.', meaning: '我是 Tom。'),
|
||||
'A0-P02': (sentence: 'What’s your name?', meaning: '你叫什么名字?'),
|
||||
'A0-P03': (sentence: 'Nice to meet you.', meaning: '很高兴认识你。'),
|
||||
'A0-P04': (sentence: 'How do you spell that?', meaning: '那个怎么拼写?'),
|
||||
'A0-P05': (sentence: 'How are you?', meaning: '你怎么样?'),
|
||||
'A0-P06': (sentence: 'I’m tired today.', meaning: '我今天很累。'),
|
||||
'A0-P07': (sentence: 'What’s your phone number?', meaning: '你的电话号码是多少?'),
|
||||
'A0-P08': (sentence: 'My number is one-three-eight.', meaning: '我的号码是一三八。'),
|
||||
'A0-P09': (sentence: 'What’s this?', meaning: '这是什么?'),
|
||||
'A0-P10': (sentence: 'It’s a pen.', meaning: '这是一支笔。'),
|
||||
'A0-P11': (sentence: 'Where are you from?', meaning: '你来自哪里?'),
|
||||
'A0-P12': (sentence: 'I’m from China.', meaning: '我来自中国。'),
|
||||
'A0-P13': (sentence: 'This is my sister.', meaning: '这是我的姐妹。'),
|
||||
'A0-P14': (sentence: 'What day is it today?', meaning: '今天星期几?'),
|
||||
'A0-P15': (sentence: 'It’s Friday.', meaning: '今天星期五。'),
|
||||
'A0-P16': (sentence: 'What time is it?', meaning: '现在几点?'),
|
||||
'A0-P17': (sentence: 'It’s three o’clock.', meaning: '现在三点。'),
|
||||
'A0-P18': (sentence: 'I like music.', meaning: '我喜欢音乐。'),
|
||||
'A0-P19': (sentence: 'Do you like coffee?', meaning: '你喜欢咖啡吗?'),
|
||||
'A0-P20': (sentence: 'Please say that again.', meaning: '请再说一遍。'),
|
||||
};
|
||||
|
||||
/// What review audio should play for [item]: the dictation sentence for a
|
||||
/// core item, otherwise the target itself when it has no slot to fill.
|
||||
String? reviewAudioText(String id, String target) =>
|
||||
a0DictationSentences[id]?.sentence ??
|
||||
(target.contains('[') ? null : target);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'a0_core.dart';
|
||||
import 'app_theme.dart';
|
||||
import 'generated_content.dart';
|
||||
import 'local_store.dart';
|
||||
import 'review_feedback.dart';
|
||||
@@ -31,6 +32,7 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
bool onboardingComplete = false;
|
||||
bool showChineseHints = true;
|
||||
bool keepRecordings = false;
|
||||
AppThemeMode themeMode = AppThemeMode.system;
|
||||
int completedLessons = 0;
|
||||
String activeLessonId = 'a0-01';
|
||||
|
||||
@@ -248,6 +250,12 @@ class AppState extends _AppStateData
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setThemeMode(AppThemeMode value) {
|
||||
if (themeMode == value) return;
|
||||
themeMode = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearProgress() {
|
||||
completedLessons = 0;
|
||||
activeLessonId = 'a0-01';
|
||||
|
||||
@@ -81,6 +81,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
ReviewItem item, {
|
||||
required bool assisted,
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
@@ -142,6 +145,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
: EvidenceKind.independentSuccess,
|
||||
assisted: assisted,
|
||||
rawAnswer: rawAnswer,
|
||||
inputMode: inputMode,
|
||||
originalTranscript: originalTranscript,
|
||||
recordingPath: recordingPath,
|
||||
);
|
||||
if (assisted) {
|
||||
_recordEvidence(current.id, EvidenceKind.assisted);
|
||||
@@ -207,20 +213,28 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
required EvidenceKind outcome,
|
||||
bool assisted = false,
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final spoken = originalTranscript != null;
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'review-${item.id}-${now.microsecondsSinceEpoch}',
|
||||
itemId: item.id,
|
||||
taskId: 'review-${item.id}',
|
||||
skill: item.skill,
|
||||
inputMode: 'text',
|
||||
inputMode: inputMode,
|
||||
outcome: outcome,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
variantIndex: item.variantIndex,
|
||||
originalTranscript: originalTranscript,
|
||||
transcriptConfirmed: spoken && originalTranscript == rawAnswer,
|
||||
transcriptEdited: spoken && originalTranscript != rawAnswer,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -315,6 +329,10 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
reviewQueue[index] = current.copyWith(
|
||||
prompt: variant.prompt,
|
||||
hint: variant.expectedAnswer,
|
||||
// An AI prompt is a written/spoken recall task, never dictation.
|
||||
skill: a0CoreItems.containsKey(current.id)
|
||||
? coreReviewTemplate(current.id).skill
|
||||
: null,
|
||||
variantIndex: current.variantIndex + 1,
|
||||
isAiGenerated: true,
|
||||
);
|
||||
@@ -461,7 +479,13 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
}
|
||||
|
||||
/// Skills whose success only shows the learner recognises the item.
|
||||
static const _recognitionSkills = {'听辨识别', '阅读识别', '听力理解', '阅读理解'};
|
||||
static const _recognitionSkills = {
|
||||
'听辨识别',
|
||||
'阅读识别',
|
||||
'听力理解',
|
||||
'阅读理解',
|
||||
dictationSkill,
|
||||
};
|
||||
|
||||
/// Controlled lesson steps (follow-reading, scripted dialogue) are practice,
|
||||
/// not attempts to recall, so they never count as a valid answer.
|
||||
@@ -470,12 +494,12 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
(entry.taskId.endsWith('-speaking') ||
|
||||
entry.taskId.endsWith('-dialogue'));
|
||||
|
||||
/// Use outside the lesson it was taught in: a free scene dialogue, an AI
|
||||
/// adaptive task, or a review answered in a changed situation.
|
||||
/// Use outside the lesson it was taught in: a free scene dialogue or an AI
|
||||
/// adaptive task. A review only re-asks the item on its own, so however its
|
||||
/// prompt is worded it is recall, not use in a situation.
|
||||
static bool _isContextUse(AttemptEvidence entry) =>
|
||||
entry.taskId.startsWith('dialogue-scene-') ||
|
||||
entry.id.startsWith('adaptive-') ||
|
||||
(entry.taskId == 'review-${entry.itemId}' && entry.variantIndex >= 1);
|
||||
entry.id.startsWith('adaptive-');
|
||||
|
||||
/// Learning engine 3.1: recognise = a recognition success; recall = an
|
||||
/// unaided spoken or written success; use = unaided use in a different
|
||||
|
||||
@@ -15,6 +15,9 @@ extension _AppStateSnapshot on AppState {
|
||||
dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes;
|
||||
showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints;
|
||||
keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings;
|
||||
themeMode =
|
||||
AppThemeMode.values.asNameMap()[data['themeMode'] as String?] ??
|
||||
themeMode;
|
||||
aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint;
|
||||
aiModel = data['aiModel'] as String? ?? aiModel;
|
||||
cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?;
|
||||
@@ -284,6 +287,7 @@ extension _AppStateSnapshot on AppState {
|
||||
'dailyMinutes': dailyMinutes,
|
||||
'showChineseHints': showChineseHints,
|
||||
'keepRecordings': keepRecordings,
|
||||
'themeMode': themeMode.name,
|
||||
'aiEndpoint': aiEndpoint,
|
||||
'aiModel': aiModel,
|
||||
'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw,
|
||||
|
||||
@@ -1,57 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract final class AppColors {
|
||||
static const paper = Color(0xFFF5F7F3);
|
||||
static const surface = Colors.white;
|
||||
static const surfaceMuted = Color(0xFFF1F5F0);
|
||||
static const ink = Color(0xFF19211B);
|
||||
static const muted = Color(0xFF647268);
|
||||
static const line = Color(0xFFD8E1D9);
|
||||
static const green = Color(0xFF176B46);
|
||||
static const softGreen = Color(0xFFE2F3E8);
|
||||
static const warm = Color(0xFFFFF0E3);
|
||||
static const warmInk = Color(0xFF9E4C18);
|
||||
/// User's theme preference, persisted in the learning snapshot.
|
||||
enum AppThemeMode { system, light, dark }
|
||||
|
||||
/// One brightness worth of brand colours. Two const instances below hold the
|
||||
/// light and dark values; [AppColors] mirrors the active one so the many
|
||||
/// widgets that read `AppColors.x` directly recolour when the theme changes.
|
||||
class _Palette {
|
||||
const _Palette({
|
||||
required this.paper,
|
||||
required this.surface,
|
||||
required this.surfaceMuted,
|
||||
required this.ink,
|
||||
required this.muted,
|
||||
required this.line,
|
||||
required this.green,
|
||||
required this.softGreen,
|
||||
required this.warm,
|
||||
required this.warmInk,
|
||||
});
|
||||
|
||||
final Color paper;
|
||||
final Color surface;
|
||||
final Color surfaceMuted;
|
||||
final Color ink;
|
||||
final Color muted;
|
||||
final Color line;
|
||||
final Color green;
|
||||
final Color softGreen;
|
||||
final Color warm;
|
||||
final Color warmInk;
|
||||
}
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
const _light = _Palette(
|
||||
paper: Color(0xFFF5F7F3),
|
||||
surface: Colors.white,
|
||||
surfaceMuted: Color(0xFFF1F5F0),
|
||||
ink: Color(0xFF19211B),
|
||||
muted: Color(0xFF647268),
|
||||
line: Color(0xFFD8E1D9),
|
||||
green: Color(0xFF176B46),
|
||||
softGreen: Color(0xFFE2F3E8),
|
||||
warm: Color(0xFFFFF0E3),
|
||||
warmInk: Color(0xFF9E4C18),
|
||||
);
|
||||
|
||||
const _dark = _Palette(
|
||||
paper: Color(0xFF11150F),
|
||||
surface: Color(0xFF1B211A),
|
||||
surfaceMuted: Color(0xFF232B22),
|
||||
ink: Color(0xFFE8EEE6),
|
||||
muted: Color(0xFF9CA99D),
|
||||
line: Color(0xFF33402F),
|
||||
green: Color(0xFF2FA36B),
|
||||
softGreen: Color(0xFF17311F),
|
||||
warm: Color(0xFF352618),
|
||||
warmInk: Color(0xFFEBAA78),
|
||||
);
|
||||
|
||||
/// Brand colours for the active brightness. Values are reassigned by
|
||||
/// [AppColors.applyBrightness]; call it before building the widget tree.
|
||||
abstract final class AppColors {
|
||||
static Color paper = _light.paper;
|
||||
static Color surface = _light.surface;
|
||||
static Color surfaceMuted = _light.surfaceMuted;
|
||||
static Color ink = _light.ink;
|
||||
static Color muted = _light.muted;
|
||||
static Color line = _light.line;
|
||||
static Color green = _light.green;
|
||||
static Color softGreen = _light.softGreen;
|
||||
static Color warm = _light.warm;
|
||||
static Color warmInk = _light.warmInk;
|
||||
|
||||
static Brightness brightness = Brightness.light;
|
||||
|
||||
static void applyBrightness(Brightness value) {
|
||||
final p = value == Brightness.dark ? _dark : _light;
|
||||
brightness = value;
|
||||
paper = p.paper;
|
||||
surface = p.surface;
|
||||
surfaceMuted = p.surfaceMuted;
|
||||
ink = p.ink;
|
||||
muted = p.muted;
|
||||
line = p.line;
|
||||
green = p.green;
|
||||
softGreen = p.softGreen;
|
||||
warm = p.warm;
|
||||
warmInk = p.warmInk;
|
||||
}
|
||||
}
|
||||
|
||||
ThemeData buildAppTheme(Brightness brightness) {
|
||||
final p = brightness == Brightness.dark ? _dark : _light;
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: AppColors.green,
|
||||
brightness: Brightness.light,
|
||||
seedColor: p.green,
|
||||
brightness: brightness,
|
||||
).copyWith(
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.ink,
|
||||
primary: AppColors.green,
|
||||
surface: p.surface,
|
||||
onSurface: p.ink,
|
||||
primary: p.green,
|
||||
onPrimary: Colors.white,
|
||||
outline: AppColors.line,
|
||||
outline: p.line,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: AppColors.paper,
|
||||
scaffoldBackgroundColor: p.paper,
|
||||
fontFamily: 'PingFang SC',
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.ink,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: p.surface,
|
||||
foregroundColor: p.ink,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
textTheme: TextTheme(
|
||||
headlineMedium: TextStyle(
|
||||
color: AppColors.ink,
|
||||
color: p.ink,
|
||||
fontSize: 26,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
color: AppColors.ink,
|
||||
color: p.ink,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
bodyLarge: TextStyle(color: AppColors.ink, fontSize: 16, height: 1.5),
|
||||
bodyMedium: TextStyle(color: AppColors.muted, fontSize: 14, height: 1.5),
|
||||
bodyLarge: TextStyle(color: p.ink, fontSize: 16, height: 1.5),
|
||||
bodyMedium: TextStyle(color: p.muted, fontSize: 14, height: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'a0_core.dart';
|
||||
import 'models.dart';
|
||||
import 'speech_compare.dart';
|
||||
|
||||
class ReviewCheckResult {
|
||||
const ReviewCheckResult({required this.complete, required this.message});
|
||||
@@ -22,6 +23,20 @@ class ReviewFeedback {
|
||||
.trim();
|
||||
|
||||
static ReviewCheckResult check(ReviewItem item, String input) {
|
||||
final dictation = item.skill == dictationSkill
|
||||
? a0DictationSentences[item.id]
|
||||
: null;
|
||||
if (dictation != null) {
|
||||
final comparison = compareSpoken(dictation.sentence, input);
|
||||
final missing = comparison.total - comparison.heardCount;
|
||||
final wrong = missing + comparison.extraWords.length;
|
||||
return ReviewCheckResult(
|
||||
complete: comparison.matches,
|
||||
message: comparison.matches
|
||||
? '听写正确。'
|
||||
: '有 $wrong 处和听到的句子不一致。再听一次,检查拼写和漏掉的词。',
|
||||
);
|
||||
}
|
||||
final complete = a0CoreItems.containsKey(item.id)
|
||||
// A word review checks the word it actually shows.
|
||||
? coreItemUsedIn(item.id, input, word: item.target)
|
||||
@@ -102,7 +117,7 @@ bool coreItemUsedIn(String id, String input, {String? word}) {
|
||||
has("o'clock") ||
|
||||
has('oclock') ||
|
||||
phrase("o'clock")),
|
||||
'A0-P18' => introduction && has('like') && tokens.length >= 3,
|
||||
'A0-P18' => (phrase('i like') || phrase('i love')) && tokens.length >= 3,
|
||||
'A0-P19' => phrase('do you like'),
|
||||
'A0-P20' =>
|
||||
phrase('please say that again') || phrase('please speak slowly'),
|
||||
|
||||
@@ -281,6 +281,14 @@ const a0SeedLessons = [
|
||||
example: 'Nice to meet you, too.',
|
||||
exampleMeaning: '我也很高兴认识你。',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-01-thanks',
|
||||
word: 'thanks',
|
||||
meaning: '谢谢',
|
||||
example: 'Thanks. Nice to meet you, too.',
|
||||
exampleMeaning: '谢谢。我也很高兴认识你。',
|
||||
ipa: '/θæŋks/',
|
||||
),
|
||||
],
|
||||
),
|
||||
SeedLesson(
|
||||
@@ -405,6 +413,14 @@ const a0SeedLessons = [
|
||||
example: 'What’s this? It’s a key.',
|
||||
exampleMeaning: '这是什么?这是钥匙。',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-05-bag',
|
||||
word: 'bag',
|
||||
meaning: '包',
|
||||
example: 'It’s a bag.',
|
||||
exampleMeaning: '这是一个包。',
|
||||
ipa: '/bæɡ/',
|
||||
),
|
||||
],
|
||||
),
|
||||
SeedLesson(
|
||||
@@ -451,6 +467,22 @@ const a0SeedLessons = [
|
||||
example: 'This is my mother.',
|
||||
exampleMeaning: '这是我的妈妈。',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-07-sister',
|
||||
word: 'sister',
|
||||
meaning: '姐妹',
|
||||
example: 'This is my sister.',
|
||||
exampleMeaning: '这是我的姐姐(或妹妹)。',
|
||||
ipa: '/ˈsɪstə(r)/',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-07-brother',
|
||||
word: 'brother',
|
||||
meaning: '兄弟',
|
||||
example: 'This is my brother.',
|
||||
exampleMeaning: '这是我的哥哥(或弟弟)。',
|
||||
ipa: '/ˈbrʌðə(r)/',
|
||||
),
|
||||
],
|
||||
),
|
||||
SeedLesson(
|
||||
@@ -496,14 +528,38 @@ const a0SeedLessons = [
|
||||
example: 'Do you like tea?',
|
||||
exampleMeaning: '你喜欢茶吗?',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-09-music',
|
||||
word: 'music',
|
||||
meaning: '音乐',
|
||||
example: 'I like music.',
|
||||
exampleMeaning: '我喜欢音乐。',
|
||||
ipa: '/ˈmjuːzɪk/',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-09-movies',
|
||||
word: 'movies',
|
||||
meaning: '电影',
|
||||
example: 'Do you like movies?',
|
||||
exampleMeaning: '你喜欢看电影吗?',
|
||||
ipa: '/ˈmuːviz/',
|
||||
),
|
||||
],
|
||||
),
|
||||
SeedLesson(
|
||||
id: 'a0-10',
|
||||
number: 10,
|
||||
title: 'A0 综合任务',
|
||||
outcome: '完成自我介绍、喜好和反问',
|
||||
outcome: '没听清时请对方重复,并完成自我介绍、喜好和反问',
|
||||
vocabulary: [
|
||||
VocabularyItem(
|
||||
id: 'a0-10-water',
|
||||
word: 'water',
|
||||
meaning: '水',
|
||||
example: 'I like water.',
|
||||
exampleMeaning: '我喜欢喝水。',
|
||||
ipa: '/ˈwɔːtər/',
|
||||
),
|
||||
VocabularyItem(
|
||||
id: 'a0-10-again',
|
||||
word: 'Please say that again.',
|
||||
@@ -531,6 +587,7 @@ class LessonActivity {
|
||||
required this.listeningQuestion,
|
||||
required this.answers,
|
||||
required this.speaking,
|
||||
this.speakingTip = '',
|
||||
required this.reading,
|
||||
required this.readingQuestion,
|
||||
required this.readingAnswer,
|
||||
@@ -544,6 +601,9 @@ class LessonActivity {
|
||||
final String listeningQuestion;
|
||||
final List<String> answers;
|
||||
final String speaking;
|
||||
|
||||
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
||||
final String speakingTip;
|
||||
final String reading;
|
||||
final String readingQuestion;
|
||||
final String readingAnswer;
|
||||
@@ -587,7 +647,7 @@ const a0Activities = <String, LessonActivity>{
|
||||
answers: ['自我介绍', '买东西', '问时间'],
|
||||
speaking: 'Hello. I’m Shen. Nice to meet you.',
|
||||
reading:
|
||||
'Mia: Hello. I’m Mia. What’s your name?\nShen: Hi. I’m Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
|
||||
'Mia: Hello. I’m Mia. What’s your name?\nShen: Hi. I’m Shen.\nMia: Nice to meet you.\nShen: Thanks. Nice to meet you, too.',
|
||||
readingQuestion: 'Shen 说的最后一句是什么?',
|
||||
readingAnswer: 'Nice to meet you, too.',
|
||||
readingOptions: [
|
||||
@@ -605,6 +665,7 @@ const a0Activities = <String, LessonActivity>{
|
||||
listeningQuestion: '这句话是什么意思?',
|
||||
answers: ['你的名字怎么拼?', '你的名字是什么?', '你来自哪里?'],
|
||||
speaking: 'S H E N',
|
||||
speakingTip: '字母之间留一个短停顿:S /es/ - H /eɪtʃ/ - E /iː/ - N /en/。先清楚,不必快。',
|
||||
reading:
|
||||
'Mia: I’m Mia. M-I-A.\nShen: Hi, Mia. I’m Shen. S-H-E-N.\nMia: How do you spell Sam?\nShen: S-A-M.',
|
||||
readingQuestion: 'Shen 自己的名字怎么拼写?',
|
||||
@@ -635,6 +696,7 @@ const a0Activities = <String, LessonActivity>{
|
||||
listeningQuestion: '你听到的是哪个号码?',
|
||||
answers: ['138', '183', '318'],
|
||||
speaking: 'My number is one-three-eight.',
|
||||
speakingTip: '号码一个数字一个数字地读,数字之间稍停:one - three - eight。',
|
||||
reading:
|
||||
'Mia: What’s your phone number?\nShen: My number is one-three-eight.\nMia: One-eight-three?\nShen: No. One-three-eight.',
|
||||
readingQuestion: 'Shen 的号码到底是哪一个?',
|
||||
@@ -650,10 +712,11 @@ const a0Activities = <String, LessonActivity>{
|
||||
listeningQuestion: '这是什么?',
|
||||
answers: ['钥匙', '书', '水'],
|
||||
speaking: 'What’s this? It’s a pen.',
|
||||
reading: 'Mia: What’s this? Is it a pen?\nShen: No. It’s a book.',
|
||||
readingQuestion: 'Shen 说那件东西是什么?',
|
||||
reading:
|
||||
'Mia: What’s this? Is it a pen?\nShen: No. It’s a book.\nMia: And what’s this?\nShen: It’s a bag.',
|
||||
readingQuestion: 'Shen 说第一件东西是什么?',
|
||||
readingAnswer: 'A book',
|
||||
readingOptions: ['A book', 'A pen', 'A phone'],
|
||||
readingOptions: ['A book', 'A pen', 'A bag'],
|
||||
writingPrompt: '写一句“这是一支笔”。',
|
||||
writingExample: 'It’s a pen.',
|
||||
independentPrompt: '不看句框,说出一个身边物品。',
|
||||
@@ -680,10 +743,10 @@ const a0Activities = <String, LessonActivity>{
|
||||
answers: ['妈妈', '朋友', '老师'],
|
||||
speaking: 'This is my family.',
|
||||
reading:
|
||||
'Mia: Who is this? Is this your father?\nShen: No. This is my mother.',
|
||||
readingQuestion: 'Shen 介绍的是哪一位家人?',
|
||||
'Mia: Who is this? Is this your father?\nShen: No. This is my mother.\nMia: And who is this? Your sister?\nShen: No. This is my brother.',
|
||||
readingQuestion: 'Shen 先介绍的是哪一位家人?',
|
||||
readingAnswer: 'My mother',
|
||||
readingOptions: ['My mother', 'My father', 'My sister'],
|
||||
readingOptions: ['My mother', 'My brother', 'My father'],
|
||||
writingPrompt: '介绍一位家人或朋友。',
|
||||
writingExample: 'This is my mother.',
|
||||
independentPrompt: '不看句框,介绍一位家人或朋友。',
|
||||
@@ -694,6 +757,7 @@ const a0Activities = <String, LessonActivity>{
|
||||
listeningQuestion: '是什么时间?',
|
||||
answers: ['星期一三点', '星期三一点', '星期一一点'],
|
||||
speaking: 'It’s three o’clock.',
|
||||
speakingTip: 'o’clock 读作 /əˈklɒk/,前面的 o 很轻。',
|
||||
reading:
|
||||
'Mia: What day is it today? Is it Tuesday?\nShen: No. It’s Monday.',
|
||||
readingQuestion: '对话中今天到底是星期几?',
|
||||
@@ -710,7 +774,7 @@ const a0Activities = <String, LessonActivity>{
|
||||
answers: ['喜欢', '不喜欢', '不知道'],
|
||||
speaking: 'I like tea. Do you like tea?',
|
||||
reading:
|
||||
'Mia: I like tea. Do you like tea?\nShen: No, I don’t. I like coffee.',
|
||||
'Mia: I like tea. Do you like tea?\nShen: No, I don’t. I like coffee and music.\nMia: Do you like movies?\nShen: Yes, I do.',
|
||||
readingQuestion: 'Shen 喜欢喝什么?',
|
||||
readingAnswer: 'Coffee',
|
||||
readingOptions: ['Coffee', 'Tea', 'Music'],
|
||||
@@ -720,19 +784,21 @@ const a0Activities = <String, LessonActivity>{
|
||||
independentHelp: 'I like … / Do you like …?',
|
||||
),
|
||||
'a0-10': LessonActivity(
|
||||
listening: 'Please say that again.',
|
||||
listeningQuestion: '对方希望什么?',
|
||||
answers: ['再说一遍', '说慢一点', '写下来'],
|
||||
speaking: 'Please speak slowly.',
|
||||
listening: 'Do you like water? Sorry, please say that again.',
|
||||
listeningQuestion: '第二个人希望对方做什么?',
|
||||
answers: ['再说一遍', '说慢一点', '给他一杯水'],
|
||||
speaking: 'Sorry, please say that again. I like water.',
|
||||
speakingTip: 'water 的 t 常读得很轻,像轻轻的 d。先清楚,不必快。',
|
||||
reading:
|
||||
'Alex: Hi! I’m Alex. What’s your name?\nShen: Hi! I’m Shen. Nice to meet you.\nAlex: Nice to meet you, too. Where are you from?\nShen: I’m from Hong Kong. Where are you from?\nAlex: I’m from London. Do you like coffee?\nShen: No, I don’t. I like tea.',
|
||||
readingQuestion: 'Shen 来自哪里、喜欢喝什么?',
|
||||
readingAnswer: 'Hong Kong, tea',
|
||||
readingOptions: ['Hong Kong, tea', 'London, coffee', 'Hong Kong, coffee'],
|
||||
writingPrompt: '写姓名、地点和喜好三句。',
|
||||
writingExample: 'I’m Shen.\nI’m from Hong Kong.\nI like tea.',
|
||||
independentPrompt: '不看帮助,完成姓名、地点、喜好和反问。',
|
||||
independentHelp: 'I’m … / I’m from … / I like …',
|
||||
'Alex: Hi! I’m Alex. Where are you from?\nShen: Sorry? Please say that again.\nAlex: Where are you from?\nShen: I’m from Hong Kong. Do you like coffee?\nAlex: No, I don’t. I like water.',
|
||||
readingQuestion: 'Alex 喜欢喝什么?',
|
||||
readingAnswer: 'Water',
|
||||
readingOptions: ['Water', 'Coffee', 'Tea'],
|
||||
writingPrompt: '写姓名、地点和喜好三句,喜好用 water。',
|
||||
writingExample: 'I’m Shen.\nI’m from Hong Kong.\nI like water.',
|
||||
independentPrompt: '不看帮助:先请对方再说一遍,再说姓名、喜欢喝什么,并反问对方。',
|
||||
independentHelp:
|
||||
'Please say that again. / I’m … / I like water. / Do you like …?',
|
||||
),
|
||||
};
|
||||
|
||||
@@ -774,6 +840,7 @@ const a0SegmentActivities = <String, LessonActivity>{
|
||||
listeningQuestion: '号码是多少?',
|
||||
answers: ['138', '183', '318'],
|
||||
speaking: 'My number is one-three-eight.',
|
||||
speakingTip: '号码一个数字一个数字地读,数字之间稍停:one - three - eight。',
|
||||
reading:
|
||||
'Mia: What’s your phone number?\nShen: My number is five-zero-two.\nMia: Five-two-zero?\nShen: No. Five-zero-two.',
|
||||
readingQuestion: 'Shen 最后确认的号码是哪一个?',
|
||||
@@ -1187,10 +1254,10 @@ const a0IndependentRequiredTerms = <String, List<List<String>>>{
|
||||
['i like', 'i love'],
|
||||
['do you like', '#question'],
|
||||
],
|
||||
// 完成姓名、地点、喜好和反问
|
||||
// 请对方重复,完成姓名、喜好和反问
|
||||
'a0-10': [
|
||||
['say that again', 'speak slowly', 'pardon'],
|
||||
_introductionTerms,
|
||||
["i'm from", 'i am from', 'from + #word'],
|
||||
['i like', 'i love'],
|
||||
['#question'],
|
||||
],
|
||||
@@ -1690,19 +1757,19 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Say: Please say that again.',
|
||||
'Say: Please speak slowly.',
|
||||
'Now ask me your name or where I am from.',
|
||||
'Say one thing you like.',
|
||||
'Tell me what you like to drink.',
|
||||
],
|
||||
hints: [
|
||||
'Please say that again.',
|
||||
'Please speak slowly.',
|
||||
'What’s your name?',
|
||||
'I like tea.',
|
||||
'I like water.',
|
||||
],
|
||||
translations: [
|
||||
'请说:Please say that again(请再说一遍)。',
|
||||
'请说:Please speak slowly(请说慢一点)。',
|
||||
'现在请问我的名字或我来自哪里。',
|
||||
'请说一件你喜欢的事物。',
|
||||
'告诉我你喜欢喝什么。',
|
||||
],
|
||||
requiredTerms: [
|
||||
['say that again', 'again', 'pardon'],
|
||||
@@ -1716,7 +1783,7 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
],
|
||||
['i like', 'i love'],
|
||||
],
|
||||
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
|
||||
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出你喜欢喝什么'],
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/// 把设备转写和示范句逐词对照。这不是发音评分:转写只说明“机器听成了什么”,
|
||||
/// 用来提示学习者哪些词没被听出来,值得再听示范、再说一次。
|
||||
class SpokenWord {
|
||||
const SpokenWord(this.text, {required this.heard});
|
||||
|
||||
/// 示范句中的原词(保留大小写和标点,便于直接显示)。
|
||||
final String text;
|
||||
final bool heard;
|
||||
}
|
||||
|
||||
class SpokenComparison {
|
||||
const SpokenComparison({required this.words, required this.extraWords});
|
||||
|
||||
final List<SpokenWord> words;
|
||||
|
||||
/// 转写里出现、但示范句没有的词。
|
||||
final List<String> extraWords;
|
||||
|
||||
int get heardCount => words.where((word) => word.heard).length;
|
||||
int get total => words.length;
|
||||
List<String> get missedWords => [
|
||||
for (final word in words)
|
||||
if (!word.heard) word.text,
|
||||
];
|
||||
|
||||
/// 全部听出且没有多余词。
|
||||
bool get matches => total > 0 && heardCount == total && extraWords.isEmpty;
|
||||
|
||||
/// 大部分词被听出(≥ 80%)。
|
||||
bool get close => total > 0 && heardCount / total >= 0.8;
|
||||
}
|
||||
|
||||
const _digitWords = [
|
||||
'zero',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
];
|
||||
|
||||
const _contractions = {
|
||||
"i'm": ['i', 'am'],
|
||||
"it's": ['it', 'is'],
|
||||
"what's": ['what', 'is'],
|
||||
"that's": ['that', 'is'],
|
||||
"he's": ['he', 'is'],
|
||||
"she's": ['she', 'is'],
|
||||
"name's": ['name', 'is'],
|
||||
"don't": ['do', 'not'],
|
||||
"you're": ['you', 'are'],
|
||||
};
|
||||
|
||||
/// 一个显示词对应的比较单位。例如 "I’m" 展开为 i/am,"138" 展开为
|
||||
/// one/three/eight,"o’clock" 统一为 oclock。
|
||||
List<String> _unitsOf(String raw) {
|
||||
var word = raw
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[‘’`´]'), "'")
|
||||
.replaceAll(RegExp(r"[^a-z0-9']"), '');
|
||||
word = word.replaceAll(RegExp(r"^'+|'+$"), '');
|
||||
if (word.isEmpty) return const [];
|
||||
if (word == "o'clock") return const ['oclock'];
|
||||
final expanded = _contractions[word];
|
||||
if (expanded != null) return expanded;
|
||||
if (RegExp(r'^\d+$').hasMatch(word)) {
|
||||
return [for (final digit in word.split('')) _digitWords[int.parse(digit)]];
|
||||
}
|
||||
return [word.replaceAll("'", '')];
|
||||
}
|
||||
|
||||
List<String> _displayWords(String text) => text
|
||||
.replaceAll(RegExp(r'[-–—/]'), ' ')
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((word) => _unitsOf(word).isNotEmpty)
|
||||
.toList();
|
||||
|
||||
List<String> _heardUnits(String transcript) => [
|
||||
for (final word in _displayWords(transcript)) ..._unitsOf(word),
|
||||
];
|
||||
|
||||
SpokenComparison compareSpoken(String expected, String transcript) {
|
||||
final display = _displayWords(expected);
|
||||
final expectedUnits = <String>[];
|
||||
final owner = <int>[];
|
||||
for (var index = 0; index < display.length; index++) {
|
||||
for (final unit in _unitsOf(display[index])) {
|
||||
expectedUnits.add(unit);
|
||||
owner.add(index);
|
||||
}
|
||||
}
|
||||
var heard = _heardUnits(transcript);
|
||||
// 拼读时转写常把字母连成一个词(S H E N → Shen),拆回单个字母。
|
||||
final spellsLetters =
|
||||
expectedUnits.isNotEmpty &&
|
||||
expectedUnits.every((unit) => RegExp(r'^[a-z]$').hasMatch(unit));
|
||||
if (spellsLetters) {
|
||||
heard = [for (final unit in heard) ...unit.split('')];
|
||||
}
|
||||
|
||||
// 最长公共子序列:保持词序,重复词也能正确对齐。
|
||||
final n = expectedUnits.length;
|
||||
final m = heard.length;
|
||||
final table = List.generate(n + 1, (_) => List.filled(m + 1, 0));
|
||||
for (var i = n - 1; i >= 0; i--) {
|
||||
for (var j = m - 1; j >= 0; j--) {
|
||||
table[i][j] = expectedUnits[i] == heard[j]
|
||||
? table[i + 1][j + 1] + 1
|
||||
: (table[i + 1][j] >= table[i][j + 1]
|
||||
? table[i + 1][j]
|
||||
: table[i][j + 1]);
|
||||
}
|
||||
}
|
||||
final unitHeard = List.filled(n, false);
|
||||
final heardUsed = List.filled(m, false);
|
||||
var i = 0;
|
||||
var j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (expectedUnits[i] == heard[j]) {
|
||||
unitHeard[i] = true;
|
||||
heardUsed[j] = true;
|
||||
i++;
|
||||
j++;
|
||||
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
||||
i++;
|
||||
} else {
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
final wordHeard = List.filled(display.length, true);
|
||||
for (var unit = 0; unit < n; unit++) {
|
||||
if (!unitHeard[unit]) wordHeard[owner[unit]] = false;
|
||||
}
|
||||
return SpokenComparison(
|
||||
words: [
|
||||
for (var index = 0; index < display.length; index++)
|
||||
SpokenWord(display[index], heard: wordHeard[index]),
|
||||
],
|
||||
extraWords: [
|
||||
for (var index = 0; index < m; index++)
|
||||
if (!heardUsed[index]) heard[index],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,8 @@ class WritingFeedback {
|
||||
]) ||
|
||||
has('clock')),
|
||||
'a0-09' => hasIntroduction && has('like') && words.length >= 3,
|
||||
'a0-10' => hasIntroduction && has('from') && has('like'),
|
||||
'a0-10' =>
|
||||
hasIntroduction && has('from') && has('like') && has('water'),
|
||||
_ => words.length >= 2,
|
||||
},
|
||||
};
|
||||
@@ -113,7 +114,7 @@ class WritingFeedback {
|
||||
'a0-07' => '用 This is my … 介绍一位家人或朋友。',
|
||||
'a0-08' => '用 It’s … 写一个星期或整点时间。',
|
||||
'a0-09' => '用 I like … 写出一种喜好。',
|
||||
'a0-10' => '分别写姓名、来自哪里和一项喜好三部分。',
|
||||
'a0-10' => '分别写姓名、来自哪里和喜好三部分,喜好用 water:I like water.',
|
||||
_ => '再补充一个完整英文句子。',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user