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.',
|
||||
_ => '再补充一个完整英文句子。',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
|
||||
'这些数字帮助你判断准备程度,但不会替代本次评估。',
|
||||
),
|
||||
),
|
||||
const SectionCard(
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'评估规则\n'
|
||||
|
||||
@@ -90,7 +90,7 @@ class _OpenScene extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
recommended ? 'A0 · 推荐' : 'A0',
|
||||
style: const TextStyle(color: AppColors.green),
|
||||
style: TextStyle(color: AppColors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -111,7 +111,7 @@ class _LockedScene extends StatelessWidget {
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -686,7 +686,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
hint!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (aiNotice != null)
|
||||
@@ -694,7 +694,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
tint: AppColors.warm,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
size: 18,
|
||||
color: AppColors.warmInk,
|
||||
@@ -703,7 +703,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
Expanded(
|
||||
child: Text(
|
||||
aiNotice!,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppColors.warmInk,
|
||||
fontSize: 13,
|
||||
),
|
||||
@@ -715,7 +715,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
if (validationError != null)
|
||||
Text(
|
||||
validationError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
@@ -757,7 +757,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
transcriptEdited
|
||||
? '你修改了设备转写:这轮按文字作答保存。'
|
||||
: '这是设备转写;未修改提交后会作为语音尝试保存。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
RecordingControls(
|
||||
recording: recording,
|
||||
@@ -771,9 +771,9 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
widget.state.keepRecordings
|
||||
? '录音只保存在本机,不会发送给 AI。'
|
||||
: '录音仅供本次回听,离开后自动删除。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
@@ -792,7 +792,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'下次可以注意:${latestFeedback!}',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
@@ -916,7 +916,7 @@ class _TurnAction extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -935,7 +935,7 @@ class _AssistChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) => ActionChip(
|
||||
label: Text(label),
|
||||
backgroundColor: AppColors.surface,
|
||||
side: const BorderSide(color: AppColors.line),
|
||||
side: BorderSide(color: AppColors.line),
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class HomePage extends StatelessWidget {
|
||||
SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.psychology_outlined, color: AppColors.green),
|
||||
Icon(Icons.psychology_outlined, color: AppColors.green),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -92,7 +92,7 @@ class HomePage extends StatelessWidget {
|
||||
isReview
|
||||
? '${(count * 2).clamp(2, 10)} 分钟'
|
||||
: stageStep?.minutes ?? '12 分钟',
|
||||
style: const TextStyle(color: AppColors.green),
|
||||
style: TextStyle(color: AppColors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -195,7 +195,7 @@ class _FrameworkNote extends StatelessWidget {
|
||||
color: AppColors.warm,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppColors.warmInk),
|
||||
SizedBox(width: 8),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/ai_service.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/speech_compare.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../core/writing_feedback.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
@@ -112,6 +113,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
correctAnswer: activity.answers.first,
|
||||
selectedAnswer: selectedAnswer,
|
||||
audioPlayed: listeningAudioPlayed,
|
||||
missed: listeningMissed,
|
||||
onSelected: (value) => setState(() {
|
||||
selectedAnswer = value;
|
||||
if (listeningOptions[value] != activity.answers.first) {
|
||||
@@ -119,11 +121,14 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
}
|
||||
}),
|
||||
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
||||
onLookup: () => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: activity.listening,
|
||||
),
|
||||
onLookup: () {
|
||||
setState(() => listeningMissed = true);
|
||||
showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: activity.listening,
|
||||
);
|
||||
},
|
||||
onContinue:
|
||||
selectedAnswer >= 0 &&
|
||||
listeningOptions[selectedAnswer] == activity.answers.first
|
||||
@@ -135,6 +140,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
content = _SpeakingStep(
|
||||
state: widget.state,
|
||||
text: activity.speaking,
|
||||
tip: activity.speakingTip,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
onContinue: widget.state.completeSpeaking,
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
validationError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (!widget.showHelp)
|
||||
|
||||
@@ -8,6 +8,7 @@ class _ListeningStep extends StatelessWidget {
|
||||
required this.correctAnswer,
|
||||
required this.selectedAnswer,
|
||||
required this.audioPlayed,
|
||||
required this.missed,
|
||||
required this.onSelected,
|
||||
required this.onPlayed,
|
||||
required this.onLookup,
|
||||
@@ -21,6 +22,9 @@ class _ListeningStep extends StatelessWidget {
|
||||
|
||||
final int selectedAnswer;
|
||||
final bool audioPlayed;
|
||||
|
||||
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
|
||||
final bool missed;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onPlayed;
|
||||
final VoidCallback onLookup;
|
||||
@@ -29,6 +33,9 @@ class _ListeningStep extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final answers = options;
|
||||
final answeredCorrectly =
|
||||
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
|
||||
final revealText = answeredCorrectly || missed;
|
||||
return _LessonScaffold(
|
||||
step: 2,
|
||||
child: SpacedColumn(
|
||||
@@ -47,22 +54,23 @@ class _ListeningStep extends StatelessWidget {
|
||||
onPlayed: onPlayed,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: onLookup,
|
||||
icon: const Icon(Icons.menu_book_outlined),
|
||||
label: const Text('查看词或短语'),
|
||||
),
|
||||
if (audioPlayed)
|
||||
SectionCard(child: LexiconText(activity.listening, state: state)),
|
||||
if (!audioPlayed)
|
||||
Text(
|
||||
'先播放音频,听完再选择。原文会在作答后显示。',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
if (revealText)
|
||||
SectionCard(child: LexiconText(activity.listening, state: state))
|
||||
else if (audioPlayed)
|
||||
TextButton.icon(
|
||||
onPressed: onLookup,
|
||||
icon: const Icon(Icons.menu_book_outlined),
|
||||
label: const Text('听不懂,查看原文和词义(本题不计入听辨)'),
|
||||
),
|
||||
for (var index = 0; index < answers.length; index++)
|
||||
SectionCard(
|
||||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||
onTap: () {
|
||||
onSelected(index);
|
||||
if (!audioPlayed) {
|
||||
onPlayed();
|
||||
}
|
||||
},
|
||||
onTap: audioPlayed ? () => onSelected(index) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -74,18 +82,23 @@ class _ListeningStep extends StatelessWidget {
|
||||
: AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(answers[index]),
|
||||
Text(
|
||||
answers[index],
|
||||
style: audioPlayed
|
||||
? null
|
||||
: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: selectedAnswer >= 0
|
||||
? '检查并继续'
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频'),
|
||||
onPressed: onContinue,
|
||||
),
|
||||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||
const Text(
|
||||
Text(
|
||||
'再听一次,选择正确答案。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
|
||||
@@ -95,7 +95,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
@@ -117,7 +117,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
onTap: () =>
|
||||
VoiceService.instance.speak(widget.activity.reading),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Padding(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
@@ -194,7 +194,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
color: AppColors.green.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'问题',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
@@ -207,7 +207,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.activity.readingQuestion,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
@@ -279,7 +279,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
color: AppColors.green.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||||
SizedBox(width: 8),
|
||||
@@ -309,7 +309,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
color: AppColors.warmInk.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
@@ -333,7 +333,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
hintText: '用英文输入答案',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
@@ -346,7 +346,7 @@ class _ReadingStepState extends State<_ReadingStep> {
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'答案:${widget.activity.readingAnswer}',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (!showAnswer &&
|
||||
|
||||
@@ -4,10 +4,12 @@ class _SpeakingStep extends StatefulWidget {
|
||||
const _SpeakingStep({
|
||||
required this.state,
|
||||
required this.text,
|
||||
this.tip = '',
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String text;
|
||||
final String tip;
|
||||
final AppState state;
|
||||
final bool keepRecording;
|
||||
final VoidCallback onContinue;
|
||||
@@ -78,21 +80,18 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'/es - eɪtʃ - iː - en/',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: _AudioRow(label: '播放示范音', speech: widget.text),
|
||||
),
|
||||
const SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'字母之间留一个短停顿。先清楚,不必快。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
if (widget.tip.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
widget.tip,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
@@ -137,9 +136,11 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
),
|
||||
),
|
||||
if (transcript.isNotEmpty)
|
||||
SectionCard(child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。")),
|
||||
_SpokenComparisonCard(expected: widget.text, transcript: transcript),
|
||||
const SectionCard(
|
||||
child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'),
|
||||
child: Text(
|
||||
'转写只反映机器听成了什么,不等于发音评分;没听出的词可以再听示范、重说一次。这一步只算跟读练习,不作为独立口语证据。',
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
||||
@@ -149,3 +150,54 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SpokenComparisonCard extends StatelessWidget {
|
||||
const _SpokenComparisonCard({
|
||||
required this.expected,
|
||||
required this.transcript,
|
||||
});
|
||||
|
||||
final String expected;
|
||||
final String transcript;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final result = compareSpoken(expected, transcript);
|
||||
final summary = result.matches
|
||||
? '每个词都被听出来了。'
|
||||
: result.close
|
||||
? '大部分词都被听出来了,橙色的词再练一次。'
|
||||
: '有几个词没被听出来:先听示范,再慢一点说。';
|
||||
return SectionCard(
|
||||
tint: result.close ? AppColors.softGreen : AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('听出 ${result.heardCount}/${result.total} 个词。$summary'),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final word in result.words)
|
||||
Text(
|
||||
word.text,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: word.heard ? AppColors.green : AppColors.warmInk,
|
||||
decoration: word.heard
|
||||
? TextDecoration.none
|
||||
: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'设备转写:$transcript',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
Text('还可补充:${aiFeedback!.missing.join('、')}'),
|
||||
if (aiFeedback!.suggestion != null)
|
||||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||
const Text(
|
||||
Text(
|
||||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -164,7 +164,7 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
aiFeedbackError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (result != null)
|
||||
|
||||
@@ -45,7 +45,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Column(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
@@ -312,7 +312,7 @@ class _PlacementPageState extends State<PlacementPage>
|
||||
label: '播放示范音',
|
||||
onPressed: () => VoiceService.instance.speak(_readAloudText),
|
||||
),
|
||||
const SectionCard(
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('这一题只是开口热身,不录音、不打分,可以跳过。'),
|
||||
),
|
||||
|
||||
@@ -130,14 +130,14 @@ class ProgressPage extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (state.a0Passed)
|
||||
const SectionCard(
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'),
|
||||
),
|
||||
@@ -311,6 +311,41 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
activeThumbColor: AppColors.green,
|
||||
onChanged: widget.state.toggleKeepRecordings,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 10, 4, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'外观主题',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'深色模式适合夜间使用;跟随系统会随手机的深色开关自动切换',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<AppThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: AppThemeMode.system,
|
||||
label: Text('跟随系统'),
|
||||
),
|
||||
ButtonSegment(value: AppThemeMode.light, label: Text('浅色')),
|
||||
ButtonSegment(value: AppThemeMode.dark, label: Text('深色')),
|
||||
],
|
||||
selected: {widget.state.themeMode},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (selection) =>
|
||||
widget.state.setThemeMode(selection.first),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SettingTile(
|
||||
title: '已保存的录音',
|
||||
subtitle: '回听或删除本机英语练习录音',
|
||||
@@ -326,7 +361,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
'离线语音识别引擎',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -335,14 +370,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
subtitle: SherpaSttService.instance.isReady
|
||||
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
|
||||
: '预加载就绪 · 已内置打包',
|
||||
trailing: const Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||||
trailing: Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
const Text(
|
||||
'云同步与多端备份',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'支持通过自建服务器在 Android / iOS / macOS 之间同步学习进度与复习掌握度。离线自动缓存,联网自动双向合并。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -363,7 +398,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
'AI 对话服务',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。密钥不在此页面保存。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -568,7 +603,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.mic_none, color: AppColors.green),
|
||||
Icon(Icons.mic_none, color: AppColors.green),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
||||
@@ -201,7 +201,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'本地优先架构:无网络时不影响学习,联网后自动双向合并学习进度与复习掌握度。',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||
),
|
||||
@@ -214,7 +214,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: AppColors.softGreen,
|
||||
child: Icon(Icons.person, color: AppColors.green),
|
||||
@@ -232,7 +232,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
|
||||
),
|
||||
Text(
|
||||
_coordinator.serverUrl,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
|
||||
@@ -27,7 +27,8 @@ class ReviewPage extends StatefulWidget {
|
||||
State<ReviewPage> createState() => _ReviewPageState();
|
||||
}
|
||||
|
||||
class _ReviewPageState extends State<ReviewPage> {
|
||||
class _ReviewPageState extends State<ReviewPage>
|
||||
with VoiceAnswerMixin<ReviewPage> {
|
||||
final controller = TextEditingController();
|
||||
bool showHint = false;
|
||||
bool usedHelp = false;
|
||||
@@ -35,29 +36,78 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
bool generatingVariant = false;
|
||||
bool generatingLesson = false;
|
||||
|
||||
/// The untouched transcript of a spoken answer; null for typed answers.
|
||||
String? transcript;
|
||||
bool dictationPlayed = false;
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VoiceService.instance.stopSpeaking();
|
||||
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetAnswer() {
|
||||
controller.clear();
|
||||
// A recording still held here was never attached to evidence.
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
transcript = null;
|
||||
recordingPath = null;
|
||||
dictationPlayed = false;
|
||||
}
|
||||
|
||||
void _next(ReviewItem item, {required bool assisted}) {
|
||||
final result = ReviewFeedback.check(item, controller.text);
|
||||
if (!result.complete) {
|
||||
setState(() => validationMessage = result.message);
|
||||
return;
|
||||
}
|
||||
final answer = controller.text.trim();
|
||||
final spoken = transcript != null;
|
||||
widget.state.completeReview(
|
||||
item,
|
||||
assisted: assisted,
|
||||
rawAnswer: controller.text.trim(),
|
||||
rawAnswer: answer,
|
||||
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
|
||||
originalTranscript: transcript,
|
||||
recordingPath: spoken && widget.state.keepRecordings
|
||||
? recordingPath
|
||||
: null,
|
||||
);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
// A kept recording now belongs to the evidence row; don't delete it.
|
||||
if (spoken && widget.state.keepRecordings) recordingPath = null;
|
||||
setState(_resetAnswer);
|
||||
}
|
||||
|
||||
Future<void> _toggleVoice() async {
|
||||
if (aiVoiceRecording) {
|
||||
await finishVoiceInput(
|
||||
keepAudio: widget.state.keepRecordings,
|
||||
onTranscript: (text) {
|
||||
controller.text = text;
|
||||
transcript = text;
|
||||
validationMessage = null;
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
await VoiceService.instance.stopSpeaking();
|
||||
await startVoiceInput(unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文作答。');
|
||||
}
|
||||
|
||||
Future<void> _play(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await VoiceService.instance.speak(text, slow: slow);
|
||||
} finally {
|
||||
if (mounted) setState(() => dictationPlayed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generateVariant(ReviewItem item) async {
|
||||
@@ -144,6 +194,11 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
final dictation = item.skill == dictationSkill
|
||||
? a0DictationSentences[item.id]
|
||||
: null;
|
||||
final spokenTask = item.skill == spokenRecallSkill;
|
||||
final audioText = reviewAudioText(item.id, item.target);
|
||||
final checkpoint =
|
||||
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
|
||||
final checkpointLabel = checkpoint >= 4
|
||||
@@ -153,19 +208,22 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
|
||||
Text('不看答案,试着回答。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(
|
||||
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
'目标技能:${item.skill}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
checkpointLabel,
|
||||
style: const TextStyle(color: AppColors.green, fontSize: 13),
|
||||
style: TextStyle(color: AppColors.green, fontSize: 13),
|
||||
),
|
||||
if (item.isAiGenerated)
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'AI 生成题面 · 已通过客户端结构审核',
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
@@ -174,12 +232,7 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.state.reportGeneratedReviewVariant(item);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
setState(_resetAnswer);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')),
|
||||
);
|
||||
@@ -192,7 +245,7 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
'情境',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
@@ -200,6 +253,22 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
),
|
||||
),
|
||||
Text(item.prompt, style: const TextStyle(fontSize: 19)),
|
||||
if (dictation != null)
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(dictation.sentence),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
|
||||
TextButton(
|
||||
onPressed: () => _play(dictation.sentence, slow: true),
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -207,14 +276,34 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
enabled: dictation == null || dictationPlayed,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你会怎么回答',
|
||||
decoration: InputDecoration(
|
||||
hintText: dictation != null
|
||||
? (dictationPlayed ? '写下你听到的英文' : '先播放句子')
|
||||
: (spokenTask ? '点下方麦克风说出来,或输入英文' : '输入你会怎么回答'),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (dictation == null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: transcribing ? null : _toggleVoice,
|
||||
icon: Icon(listening ? Icons.stop : Icons.mic_none),
|
||||
label: Text(
|
||||
transcribing
|
||||
? '正在识别…'
|
||||
: listening
|
||||
? '说完了,停止并识别'
|
||||
: '用语音回答',
|
||||
),
|
||||
),
|
||||
if (transcript != null && controller.text.trim() != transcript)
|
||||
Text(
|
||||
'你修改了语音转写,本次按文字作答记录。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -231,19 +320,14 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
label: const Text('稍后复习'),
|
||||
onPressed: () {
|
||||
widget.state.postponeReview(item);
|
||||
setState(() {});
|
||||
setState(_resetAnswer);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('暂时想不起来'),
|
||||
onPressed: () {
|
||||
widget.state.reportReviewFailure(item);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
setState(_resetAnswer);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
@@ -262,25 +346,36 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.search, size: 16),
|
||||
label: const Text('查词查句'),
|
||||
onPressed: () => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
),
|
||||
onPressed: () =>
|
||||
showLexiconLookup(context, state: widget.state),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showHint)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'参考:${item.hint}\n目标:${item.target}',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
dictation != null
|
||||
? '句子意思:${item.hint}\n目标词句:${item.target}'
|
||||
: '参考:${item.hint}\n目标:${item.target}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (audioText != null && dictation == null)
|
||||
TextButton.icon(
|
||||
onPressed: () => _play(audioText),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: Text('听示范:$audioText'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (validationMessage != null)
|
||||
Text(
|
||||
validationMessage!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
||||
@@ -288,7 +383,7 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
? null
|
||||
: () => _next(item, assisted: usedHelp),
|
||||
),
|
||||
const Text(
|
||||
Text(
|
||||
'提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -439,9 +534,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
);
|
||||
return;
|
||||
}
|
||||
await startVoiceInput(
|
||||
unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
|
||||
);
|
||||
await startVoiceInput(unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -508,7 +601,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
Text(
|
||||
'审核:${widget.state.cachedAdaptiveLessonAuditor ?? '已配置服务'} · '
|
||||
'${_formatAuditTime(widget.state.cachedAdaptiveLessonAuditedAt!)}',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
Text(task.prompt, style: Theme.of(context).textTheme.headlineMedium),
|
||||
if (showStimulus)
|
||||
@@ -517,7 +610,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
child: Text(task.stimulus, style: const TextStyle(fontSize: 20)),
|
||||
)
|
||||
else
|
||||
const SectionCard(
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('先播放音频,再输入你听到的答案。'),
|
||||
),
|
||||
@@ -579,9 +672,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.mic_none,
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
color: listening ? AppColors.green : null,
|
||||
),
|
||||
),
|
||||
@@ -639,7 +730,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
if (answerFeedback != null)
|
||||
Text(
|
||||
answerFeedback!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (showReference)
|
||||
SectionCard(
|
||||
|
||||
@@ -121,6 +121,11 @@ class _LearningShellState extends State<LearningShell> {
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
// 换课或换段时重建,避免上一段的听力选择和播放状态沿用过来。
|
||||
key: ValueKey(
|
||||
'${widget.state.activeLessonId}-'
|
||||
'${widget.state.activeSegmentIndexFor(widget.state.activeLessonId)}',
|
||||
),
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(tab),
|
||||
@@ -333,7 +338,7 @@ class _LearningMap extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
"复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。",
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
SecondaryButton(label: "先去复习", onPressed: onOpenReview),
|
||||
],
|
||||
|
||||
+51
-34
@@ -47,43 +47,60 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static ThemeMode _themeMode(AppThemeMode mode) => switch (mode) {
|
||||
AppThemeMode.system => ThemeMode.system,
|
||||
AppThemeMode.light => ThemeMode.light,
|
||||
AppThemeMode.dark => ThemeMode.dark,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '芽说英语 · SpeakSprout',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
home: AnimatedBuilder(
|
||||
animation: appState,
|
||||
builder: (context, _) {
|
||||
if (!appState.isLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (appState.onboardingComplete) {
|
||||
return LearningShell(state: appState);
|
||||
}
|
||||
if (onboardingStep == 0) {
|
||||
return WelcomePage(
|
||||
state: appState,
|
||||
onContinue: () => setState(() => onboardingStep = 1),
|
||||
);
|
||||
}
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
setState(() => onboardingStep = 0);
|
||||
}
|
||||
},
|
||||
child: PlacementPage(
|
||||
state: appState,
|
||||
onBack: () => setState(() => onboardingStep = 0),
|
||||
onStart: () => appState.finishOnboarding(),
|
||||
),
|
||||
);
|
||||
return AnimatedBuilder(
|
||||
animation: appState,
|
||||
builder: (context, _) => MaterialApp(
|
||||
title: '芽说英语 · SpeakSprout',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(Brightness.light),
|
||||
darkTheme: buildAppTheme(Brightness.dark),
|
||||
themeMode: _themeMode(appState.themeMode),
|
||||
// Mirror the resolved brightness into the global palette that many
|
||||
// widgets read directly, so it tracks both the user's choice and any
|
||||
// system dark-mode change.
|
||||
builder: (context, child) {
|
||||
AppColors.applyBrightness(Theme.of(context).brightness);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
if (!appState.isLoaded) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (appState.onboardingComplete) {
|
||||
return LearningShell(state: appState);
|
||||
}
|
||||
if (onboardingStep == 0) {
|
||||
return WelcomePage(
|
||||
state: appState,
|
||||
onContinue: () => setState(() => onboardingStep = 1),
|
||||
);
|
||||
}
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
setState(() => onboardingStep = 0);
|
||||
}
|
||||
},
|
||||
child: PlacementPage(
|
||||
state: appState,
|
||||
onBack: () => setState(() => onboardingStep = 0),
|
||||
onStart: () => appState.finishOnboarding(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class SecondaryButton extends StatelessWidget {
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.ink,
|
||||
side: const BorderSide(color: AppColors.line),
|
||||
side: BorderSide(color: AppColors.line),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
@@ -139,7 +139,7 @@ class Eyebrow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -165,7 +165,7 @@ class _LexiconTextState extends State<LexiconText> {
|
||||
TextSpan(
|
||||
text: matched,
|
||||
recognizer: recognizer,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.green,
|
||||
@@ -401,7 +401,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
child: SpacedColumn(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.translate, size: 16, color: AppColors.green),
|
||||
SizedBox(width: 6),
|
||||
@@ -464,7 +464,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (analysis.pronunciationTips != null &&
|
||||
analysis.pronunciationTips!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Row(
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.record_voice_over_outlined,
|
||||
@@ -522,7 +522,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.auto_stories_outlined,
|
||||
size: 18,
|
||||
color: AppColors.green,
|
||||
@@ -560,7 +560,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (phrase.ipa != null && phrase.ipa!.isNotEmpty)
|
||||
Text(
|
||||
phrase.ipa!,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
@@ -575,7 +575,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_addedToReview.contains(phrase.phrase.toLowerCase())
|
||||
? const Chip(
|
||||
? Chip(
|
||||
label: Text('已在复习', style: TextStyle(fontSize: 12)),
|
||||
avatar: Icon(Icons.check, size: 14, color: AppColors.green),
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -602,7 +602,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (phrase.usageNote != null && phrase.usageNote!.isNotEmpty)
|
||||
Text(
|
||||
'用法:${phrase.usageNote}',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
@@ -618,7 +618,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
children: [
|
||||
Text(
|
||||
'解析引擎:${analysis.provider} · 本机已缓存',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
|
||||
@@ -666,7 +666,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
color: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
child: Text(
|
||||
'已解析',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -769,7 +769,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.layers_outlined, size: 18, color: AppColors.green),
|
||||
Icon(Icons.layers_outlined, size: 18, color: AppColors.green),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'本地词典匹配到的短语 (${localPhrases.length})',
|
||||
@@ -804,7 +804,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_addedToReview.contains(phraseItem.word.toLowerCase())
|
||||
? const Icon(Icons.check, size: 18, color: AppColors.green)
|
||||
? Icon(Icons.check, size: 18, color: AppColors.green)
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -823,7 +823,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (phraseItem.example.isNotEmpty)
|
||||
Text(
|
||||
'例:${phraseItem.example} (${phraseItem.exampleMeaning})',
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
@@ -865,7 +865,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (sentenceAnalysisError != null)
|
||||
Text(
|
||||
sentenceAnalysisError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -888,7 +888,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
if (temporaryError != null)
|
||||
Text(
|
||||
temporaryError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
|
||||
if (temporaryDefinition != null)
|
||||
|
||||
Reference in New Issue
Block a user