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:
shenlei
2026-09-18 11:16:09 +09:00
co-authored by Claude Opus 4.8
parent 9ab08c25ef
commit febfd30f49
93 changed files with 72676 additions and 274 deletions
+149
View File
@@ -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'],
};
/// 一个显示词对应的比较单位。例如 "Im" 展开为 i/am"138" 展开为
/// one/three/eight"oclock" 统一为 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],
],
);
}