feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
/// Frozen A0 v1 upgrade denominator from A0-STAGE-STANDARD.md.
/// IDs are intentionally separate from generated wording and never change
/// when a course uses an accepted slot value or contraction.
final Map<String, String> a0CoreItems = Map.unmodifiable({
for (final entry in _numbered('A0-W', const [
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
'book',
'phone',
'pen',
'bag',
'key',
'water',
'mother',
'father',
'sister',
'brother',
'coffee',
'tea',
'music',
'movies',
'good',
'okay',
'tired',
'hello',
'hi',
'yes',
'no',
'thanks',
]))
entry.key: entry.value,
for (final entry in _numbered('A0-P', const [
"I'm [name].",
"What's your name?",
'Nice to meet you.',
'How do you spell that?',
'How are you?',
"I'm [state].",
"What's your phone number?",
'My number is [digits].',
"What's this?",
"It's a [object].",
'Where are you from?',
"I'm from [place].",
'This is my [person].',
'What day is it today?',
"It's [weekday].",
'What time is it?',
"It's [hour] o'clock.",
'I like [thing].',
'Do you like [thing]?',
'Please say that again.',
]))
entry.key: entry.value,
});
Iterable<MapEntry<String, String>> _numbered(
String prefix,
List<String> values,
) sync* {
for (var index = 0; index < values.length; index++) {
yield MapEntry(
'$prefix${(index + 1).toString().padLeft(2, '0')}',
values[index],
);
}
}
class CoreReviewTemplate {
const CoreReviewTemplate({
required this.prompt,
required this.hint,
required this.skill,
});
final String prompt;
final String hint;
final String skill;
}
/// Offline, reviewed prompts keep initial reviews usable without an AI service.
/// The prompt supplies a meaning or situation, never the English target.
CoreReviewTemplate coreReviewTemplate(String id) {
final target = a0CoreItems[id] ?? id;
const meanings = {
'A0-W01': '',
'A0-W02': '',
'A0-W03': '',
'A0-W04': '',
'A0-W05': '',
'A0-W06': '',
'A0-W07': '',
'A0-W08': '',
'A0-W09': '',
'A0-W10': '',
'A0-W11': '',
'A0-W12': '星期一',
'A0-W13': '星期二',
'A0-W14': '星期三',
'A0-W15': '星期四',
'A0-W16': '星期五',
'A0-W17': '星期六',
'A0-W18': '星期日',
'A0-W19': '',
'A0-W20': '电话',
'A0-W21': '',
'A0-W22': '',
'A0-W23': '钥匙',
'A0-W24': '',
'A0-W25': '妈妈',
'A0-W26': '爸爸',
'A0-W27': '姐妹',
'A0-W28': '兄弟',
'A0-W29': '咖啡',
'A0-W30': '',
'A0-W31': '音乐',
'A0-W32': '电影',
'A0-W33': '',
'A0-W34': '还可以',
'A0-W35': '',
'A0-W36': '你好',
'A0-W37': '',
'A0-W38': '',
'A0-W39': '',
'A0-W40': '谢谢',
};
final phrasePrompt = switch (id) {
'A0-P01' => '向新同学介绍你的名字。',
'A0-P02' => '问对方叫什么名字。',
'A0-P03' => '对初次见面的人说“很高兴认识你”。',
'A0-P04' => '问对方一个词怎样拼写。',
'A0-P05' => '问对方今天怎么样。',
'A0-P06' => '说出你今天的状态。',
'A0-P07' => '问对方的电话号码。',
'A0-P08' => '报出一个虚构的三位号码。',
'A0-P09' => '指着身边的东西问“这是什么”。',
'A0-P10' => '说出“这是一件物品”。',
'A0-P11' => '问对方来自哪里。',
'A0-P12' => '说出你来自哪里。',
'A0-P13' => '介绍一位家人。',
'A0-P14' => '问今天星期几。',
'A0-P15' => '说出今天星期几。',
'A0-P16' => '问现在几点。',
'A0-P17' => '说出一个整点时间。',
'A0-P18' => '说出你喜欢的一样东西。',
'A0-P19' => '问对方是否喜欢一样东西。',
'A0-P20' => '请对方再说一次。',
_ => null,
};
if (phrasePrompt != null) {
return CoreReviewTemplate(
prompt: phrasePrompt,
hint: target,
skill: '回忆表达',
);
}
return CoreReviewTemplate(
prompt: '把“${meanings[id] ?? '这个已学词'}”写成英文。',
hint: target,
skill: '词汇回忆',
);
}
/// Rotates an approved offline prompt family without changing the core target
/// ID. These are a safe fallback when a dynamic AI variant is unavailable.
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
final base = coreReviewTemplate(id);
final lead = switch (variantIndex % 3) {
0 => '换一个人物或地点,',
1 => '在新的生活情境中,',
_ => '不看上次答案,',
};
return CoreReviewTemplate(
prompt: '$lead${base.prompt}',
hint: base.hint,
skill: base.skill,
);
}
+730
View File
@@ -0,0 +1,730 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'models.dart';
import 'generated_content.dart';
import 'a0_core.dart';
class AiConnectionResult {
const AiConnectionResult({required this.ok, required this.message});
final bool ok;
final String message;
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
await _secureStorage.delete(key: _keyName);
} else {
await _secureStorage.write(key: _keyName, value: value.trim());
}
}
Future<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
Future<String?> temporaryDefinition({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
if (provider == AiProviderType.mock ||
text.length > 120 ||
text.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
uri.scheme != 'https') {
return null;
}
const instruction =
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': '$instruction\nText: $text'},
],
},
],
'generationConfig': {
'maxOutputTokens': 100,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
'max_tokens': 100,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final raw = _extractResponseContent(provider, response.body);
final data = raw == null ? null : jsonDecode(raw);
final definition = data is Map ? data['definition'] : null;
return definition is String &&
definition.trim().isNotEmpty &&
definition.length <= 160
? definition.trim()
: null;
} catch (_) {
return null;
}
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
required String model,
}) async {
if (provider == AiProviderType.mock) {
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
}
const probe =
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
ok: false,
message: '请填写 Base URL 和模型名称。',
);
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
}
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': probe},
],
},
],
'generationConfig': {
'maxOutputTokens': 80,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式。',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
);
} catch (_) {
return const AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
);
}
}
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
],
},
'contents': history
.map(
(turn) => {
'role': turn['role'] == 'assistant'
? 'model'
: 'user',
'parts': [
{'text': turn['content']},
],
},
)
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
'temperature': 0.3,
'max_tokens': 60,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
return _decodeDialogueResponse(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return null;
}
}
/// Generates only a bounded variant of an existing review target. A network
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
Future<GeneratedReviewVariant?> generateReviewVariant({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String basePrompt,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $answer''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 240,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
final instruction =
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
'targetItemIds': lesson.targetItemIds,
'tasks': lesson.tasks
.map(
(task) => {
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'answerSpec': {
'requiredAnyPhrases': task.localAnswerSpec.requiredAnyPhrases,
'acceptedAnswers': task.localAnswerSpec.acceptedAnswers,
'forbiddenPhrases': task.localAnswerSpec.forbiddenPhrases,
},
},
)
.toList(),
});
final instruction =
'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 120,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return false;
}
return _decodeLessonAudit(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return false;
}
}
bool _decodeLessonAudit(String? raw) {
if (raw == null || raw.length > 600) return false;
try {
final data = jsonDecode(raw);
return data is Map<String, dynamic> &&
data.length == 3 &&
data['schemaVersion'] == 'lesson-audit-1' &&
data['approved'] == true &&
data['reason'] is String &&
(data['reason'] as String).length <= 160;
} catch (_) {
return false;
}
}
String? _extractResponseContent(AiProviderType provider, String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
if (provider == AiProviderType.gemini) {
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
final candidateContent = candidate?['content'] as Map?;
final parts = candidateContent?['parts'] as List?;
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
} catch (_) {
return null;
}
}
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
return null;
}
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
}
return DialogueAiResponse(
reply: reply.trim(),
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
);
} catch (_) {
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
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);
}
ThemeData buildAppTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: AppColors.green,
brightness: Brightness.light,
).copyWith(
surface: AppColors.surface,
onSurface: AppColors.ink,
primary: AppColors.green,
onPrimary: Colors.white,
outline: AppColors.line,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.paper,
fontFamily: 'PingFang SC',
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.ink,
elevation: 0,
centerTitle: false,
),
textTheme: const TextTheme(
headlineMedium: TextStyle(
color: AppColors.ink,
fontSize: 26,
height: 1.25,
fontWeight: FontWeight.w600,
letterSpacing: -0.6,
),
titleLarge: TextStyle(
color: AppColors.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),
),
);
}
+302
View File
@@ -0,0 +1,302 @@
import 'models.dart';
import 'writing_feedback.dart';
class AssessmentTask {
const AssessmentTask({
required this.id,
required this.skill,
required this.prompt,
this.audio,
this.choices = const [],
this.answerIndex,
this.requiredWords = const [],
required this.abilityId,
});
final String id;
final AssessmentSkill skill;
final String prompt;
final String? audio;
final List<String> choices;
final int? answerIndex;
final List<String> requiredWords;
final String abilityId;
}
class AssessmentPack {
const AssessmentPack({required this.id, required this.tasks});
final String id;
final List<AssessmentTask> tasks;
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
tasks.where((task) => task.skill == skill).toList();
}
final a0AssessmentPacks = [
AssessmentPack(
id: 'A0-E1',
tasks: _tasks(
'A',
const [
'Hello. Im Mia.|自我介绍|买东西|问时间|0',
'How do you spell your name?|问姓名拼写|问地点|问时间|0',
'Im tired today.|很好|累|来自哪里|1',
'seven|6|7|8|1',
'one-three-eight|138|183|318|0',
'Its a key.|书|钥匙|包|1',
'Im from Hong Kong.|香港|伦敦|北京|0',
'This is my mother.|妈妈|姐姐|朋友|0',
'It is Monday.|星期一|星期二|星期三|0',
'I like tea.|茶|咖啡|电影|0',
],
const [
'Mia: Im from Hong Kong.\nShen: Im good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0',
'Mia: It is Monday.\nShen: It is three oclock.|几点?|three|one|0',
'Mia: This is my brother.\nShen: Nice to meet you.|谁是家人?|brother|mother|0',
'Mia: I like music.\nShen: I like tea.|Shen 喜欢什么?|tea|music|0',
'Mia: Whats this?\nShen: Its a pen.|这是什么?|pen|bag|0',
],
const [
'用英语介绍自己。|i,m',
'用英语说明你来自哪里。|i,m,from',
'用英语说一个喜好。|i,like',
'用英语说“这是一支笔”。|it,s,pen',
'用英语说明星期或整点。|it,s',
],
const [
'介绍姓名、地点、状态/喜好并反问。|hello,i,m,from',
'完整拼读一个四字母姓名。|a,l,e,x',
'说一个三位虚拟号码。|one,three,eight',
'介绍一位家人。|this,my',
'说明星期。|it,s,monday',
'说明整点。|it,s,o,clock',
'请求对方重复或放慢。|please',
'说出一个物品。|it,s',
],
),
),
AssessmentPack(
id: 'A0-E2',
tasks: _tasks(
'B',
const [
'Hi. My name is Leo.|自我介绍|买东西|问号码|0',
'How do you spell that?|问姓名拼写|问喜好|问家人|0',
'Im okay.|很好|还可以|累|1',
'four|3|4|5|1',
'two-nine-six|296|269|926|0',
'Its a bag.|笔|包|水|1',
'Im from London.|伦敦|香港|北京|0',
'This is my sister.|姐姐|妈妈|朋友|0',
'It is Friday.|星期五|星期一|星期日|0',
'I like coffee.|茶|咖啡|音乐|1',
],
const [
'Mia: Im from London.\nLeo: Im okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0',
'Mia: It is Friday.\nLeo: It is six oclock.|几点?|six|three|0',
'Mia: This is my father.\nLeo: Hi!|谁是家人?|father|brother|0',
'Mia: I like movies.\nLeo: I like coffee.|Leo 喜欢什么?|coffee|movies|0',
'Mia: Whats this?\nLeo: Its water.|这是什么?|water|book|0',
],
const [
'用英语介绍自己。|my,name,is',
'用英语说明你来自哪里。|i,m,from',
'用英语说一个喜好。|i,like',
'用英语说“这是一个包”。|it,s,bag',
'用英语说明星期或整点。|it,s',
],
const [
'介绍姓名、地点、状态/喜好并反问。|hi,i,m,from',
'完整拼读一个四字母姓名。|l,e,o,n',
'说一个三位虚拟号码。|two,nine,six',
'介绍一位家人。|this,my',
'说明星期。|it,s,friday',
'说明整点。|it,s,o,clock',
'请求对方重复或放慢。|please',
'说出一个物品。|it,s',
],
),
),
];
/// Replacement packs use different people, places, numbers, days and likes.
/// They keep the same assessed ability but are not immediate replays.
final a0ReplacementPacks = [
_replacementPack(a0AssessmentPacks[0], 'A0-E1R', const {
'Mia': 'Nora',
'Shen': 'Kai',
'Hong Kong': 'London',
'Monday': 'Friday',
'one-three-eight': 'two-four-seven',
'138': '247',
'three oclock': 'six oclock',
'tea': 'coffee',
'key': 'bag',
'mother': 'father',
}),
_replacementPack(a0AssessmentPacks[1], 'A0-E2R', const {
'Mia': 'Ava',
'Leo': 'Bo',
'London': 'Hong Kong',
'Friday': 'Tuesday',
'two-nine-six': 'five-one-four',
'296': '514',
'six oclock': 'two oclock',
'coffee': 'music',
'bag': 'book',
'sister': 'brother',
}),
];
AssessmentPack? replacementFor(String packId) => switch (packId) {
'A0-E1' => a0ReplacementPacks[0],
'A0-E2' => a0ReplacementPacks[1],
_ => null,
};
/// Local checks for the frozen A0 exit tasks. They accept variable names and
/// places, but require the communicative information named by each task.
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool phrase(String value) => text.contains(value);
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final position = int.tryParse(
RegExp(r'(\d+)$').firstMatch(task.id)?.group(1) ?? '',
);
if (position == null) return false;
if (task.skill == AssessmentSkill.writing) {
const lessonIds = ['a0-01', 'a0-06', 'a0-09', 'a0-05', 'a0-08'];
return position >= 1 &&
position <= lessonIds.length &&
WritingFeedback.check(lessonIds[position - 1], input).complete;
}
if (task.skill != AssessmentSkill.speaking) return false;
return switch (position) {
1 =>
hasAny(['hello', 'hi']) &&
introduction &&
has('from') &&
(has('like') || hasAny(['good', 'okay', 'tired'])) &&
hasAny(['what', 'where', 'how', 'do']),
2 => RegExp(r'[a-z](?:[ -]?[a-z]){3,}').hasMatch(text),
3 => numberWords >= 3,
4 =>
phrase('this is my') && hasAny(['mother', 'father', 'sister', 'brother']),
5 =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
6 =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
7 => has('please') && hasAny(['say', 'speak', 'repeat', 'slow', 'slowly']),
8 => itIs && hasAny(['book', 'pen', 'bag', 'key', 'water']),
_ => false,
};
}
AssessmentPack _replacementPack(
AssessmentPack source,
String id,
Map<String, String> values,
) {
String replace(String value) {
var result = value;
for (final entry in values.entries) {
result = result.replaceAll(entry.key, entry.value);
}
return result;
}
return AssessmentPack(
id: id,
tasks: source.tasks
.map(
(task) => AssessmentTask(
id: task.id.replaceFirst(
source.id.split('-').last,
id.split('-').last,
),
skill: task.skill,
prompt: replace(task.prompt),
audio: task.audio == null ? null : replace(task.audio!),
choices: task.choices.map(replace).toList(),
answerIndex: task.answerIndex,
requiredWords: task.requiredWords.map(replace).toList(),
abilityId: task.abilityId,
),
)
.toList(),
);
}
List<AssessmentTask> _tasks(
String version,
List<String> listening,
List<String> reading,
List<String> writing,
List<String> speaking,
) {
AssessmentTask choice(String raw, AssessmentSkill skill, int index) {
final fields = raw.split('|');
return AssessmentTask(
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
skill: skill,
audio: skill == AssessmentSkill.listening ? fields[0] : null,
prompt: skill == AssessmentSkill.listening ? '选择正确答案' : fields[0],
choices: skill == AssessmentSkill.listening
? fields.sublist(1, 4)
: fields.sublist(1, 3),
answerIndex: int.parse(fields.last),
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
);
}
AssessmentTask open(String raw, AssessmentSkill skill, int index) {
final fields = raw.split('|');
return AssessmentTask(
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
skill: skill,
prompt: fields[0],
requiredWords: fields[1].split(','),
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
);
}
return [
...List.generate(
listening.length,
(i) => choice(listening[i], AssessmentSkill.listening, i),
),
...List.generate(
reading.length,
(i) => choice(reading[i], AssessmentSkill.reading, i),
),
...List.generate(
writing.length,
(i) => open(writing[i], AssessmentSkill.writing, i),
),
...List.generate(
speaking.length,
(i) => open(speaking[i], AssessmentSkill.speaking, i),
),
];
}
@@ -0,0 +1,580 @@
import 'dart:convert';
import 'a0_core.dart';
import 'models.dart';
/// A bounded AI-authored variation of an existing review item. It never
/// creates a core identity and is only usable after local schema validation.
class GeneratedReviewVariant {
const GeneratedReviewVariant({
required this.variantId,
required this.targetItemId,
required this.prompt,
required this.expectedAnswer,
});
final String variantId;
final String targetItemId;
final String prompt;
final String expectedAnswer;
}
/// A bounded, AI-authored adaptive lesson. It is deliberately a data object,
/// not a free-form chat response: the client can validate every target and
/// task before deciding whether to cache or display it.
class GeneratedLessonTask {
const GeneratedLessonTask({
required this.taskId,
required this.skill,
required this.type,
required this.prompt,
required this.stimulus,
required this.answer,
required this.targetItemIds,
this.answerSpec,
});
final String taskId;
final String skill;
final String type;
final String prompt;
final String stimulus;
final String answer;
final List<String> targetItemIds;
final GeneratedAnswerSpec? answerSpec;
GeneratedAnswerSpec get localAnswerSpec =>
answerSpec ?? GeneratedAnswerSpec.referenceOnly(answer);
}
/// A finite, client-verifiable boundary for a generated answer. It supports
/// specific accepted variants and required information slots without handing
/// an open-ended semantic judgement to the AI provider.
class GeneratedAnswerSpec {
const GeneratedAnswerSpec({
required this.requiredAnyPhrases,
required this.acceptedAnswers,
this.forbiddenPhrases = const [],
});
factory GeneratedAnswerSpec.referenceOnly(String answer) =>
GeneratedAnswerSpec(
requiredAnyPhrases: [
[answer],
],
acceptedAnswers: [answer],
);
final List<List<String>> requiredAnyPhrases;
final List<String> acceptedAnswers;
final List<String> forbiddenPhrases;
}
class GeneratedLesson {
const GeneratedLesson({
required this.lessonId,
required this.revision,
required this.stageVersion,
required this.abilityIds,
required this.prerequisiteIds,
required this.targetItemIds,
required this.receptiveChunks,
required this.previewItemIds,
required this.estimatedMinutes,
required this.tasks,
});
final String lessonId;
final int revision;
final String stageVersion;
final List<String> abilityIds;
final List<String> prerequisiteIds;
final List<String> targetItemIds;
final List<String> receptiveChunks;
final List<String> previewItemIds;
final int estimatedMinutes;
final List<GeneratedLessonTask> tasks;
}
/// Conservative client-side check for audited, bounded adaptive tasks. It
/// accepts explicitly approved phrases or all required information slots,
/// ignoring harmless case/punctuation variation and allowing extra words.
/// This is intentionally unavailable for free-form AI content and never
/// trusts a model's own "correct" claim.
bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
final actual = _normalizeAnswer(answer);
if (actual.isEmpty) return false;
final spec = task.localAnswerSpec;
if (spec.forbiddenPhrases.any((phrase) => _containsPhrase(actual, phrase))) {
return false;
}
final accepted = spec.acceptedAnswers.any(
(phrase) => _containsPhrase(actual, phrase),
);
final slots =
spec.requiredAnyPhrases.isNotEmpty &&
spec.requiredAnyPhrases.every(
(alternatives) =>
alternatives.any((phrase) => _containsPhrase(actual, phrase)),
);
return accepted || slots;
}
String _normalizeAnswer(String value) => value
.toLowerCase()
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
bool _containsPhrase(String normalizedAnswer, String phrase) {
final normalizedPhrase = _normalizeAnswer(phrase);
return normalizedPhrase.isNotEmpty &&
' $normalizedAnswer '.contains(' $normalizedPhrase ');
}
GeneratedReviewVariant? decodeGeneratedReviewVariant(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 1600) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 5) return null;
if (data['schemaVersion'] != 'review-variant-1') return null;
final variantId = data['variantId'];
final targetItemId = data['targetItemId'];
final prompt = data['prompt'];
final expectedAnswer = data['expectedAnswer'];
if (variantId is! String ||
targetItemId is! String ||
prompt is! String ||
expectedAnswer is! String ||
variantId.length > 80 ||
prompt.trim().isEmpty ||
prompt.length > 220 ||
expectedAnswer.trim().isEmpty ||
expectedAnswer.length > 120 ||
targetItemId != expectedTargetItemId ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
return GeneratedReviewVariant(
variantId: variantId,
targetItemId: targetItemId,
prompt: prompt.trim(),
expectedAnswer: expectedAnswer.trim(),
);
} catch (_) {
return null;
}
}
GeneratedLesson? decodeGeneratedLesson(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 12000) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic>) return null;
const required = {
'schemaVersion',
'lessonId',
'revision',
'stageVersion',
'source',
'status',
'abilityIds',
'prerequisiteIds',
'targetItemIds',
'receptiveChunks',
'newItemIds',
'previewItemIds',
'estimatedMinutes',
'tasks',
};
if (data.length != required.length ||
!data.keys.every(required.contains) ||
!(data['schemaVersion'] == 'lesson-1' ||
data['schemaVersion'] == 'lesson-2') ||
data['source'] != 'aiGenerated' ||
data['status'] != 'validated') {
return null;
}
final lessonId = data['lessonId'];
final revision = data['revision'];
final stageVersion = data['stageVersion'];
final estimatedMinutes = data['estimatedMinutes'];
final abilityIds = _stringList(data['abilityIds'], max: 4);
final prerequisites = _stringList(data['prerequisiteIds'], max: 8);
final targets = _stringList(data['targetItemIds'], max: 8);
final chunks = _stringList(data['receptiveChunks'], max: 2);
final newItems = _stringList(data['newItemIds'], max: 8);
final preview = _stringList(data['previewItemIds'], max: 8);
final rawTasks = data['tasks'];
if (lessonId is! String ||
!RegExp(r'^ai-a0-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
revision is! int ||
revision < 1 ||
stageVersion != 'A0-1.0' ||
estimatedMinutes is! int ||
estimatedMinutes < 8 ||
estimatedMinutes > 15 ||
abilityIds == null ||
prerequisites == null ||
targets == null ||
chunks == null ||
newItems == null ||
preview == null ||
rawTasks is! List ||
targets.length != 1 ||
targets.single != expectedTargetItemId ||
!a0CoreItems.containsKey(expectedTargetItemId) ||
newItems.isNotEmpty ||
preview.isNotEmpty ||
chunks.isNotEmpty ||
rawTasks.length != 4) {
return null;
}
final tasks = <GeneratedLessonTask>[];
const skills = {'listening', 'speaking', 'reading', 'writing'};
const types = {'listenChoice', 'repeat', 'readAnswer', 'writeAnswer'};
for (final rawTask in rawTasks) {
if (rawTask is! Map<String, dynamic>) return null;
const taskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
'answerSpec',
};
const legacyTaskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
};
final allowedTaskKeys = data['schemaVersion'] == 'lesson-2'
? taskKeys
: legacyTaskKeys;
if (rawTask.length != allowedTaskKeys.length ||
!rawTask.keys.every(allowedTaskKeys.contains)) {
return null;
}
final taskId = rawTask['taskId'];
final skill = rawTask['skill'];
final type = rawTask['type'];
final prompt = rawTask['prompt'];
final stimulus = rawTask['stimulus'];
final answer = rawTask['answer'];
final taskTargets = _stringList(rawTask['targetItemIds'], max: 1);
final answerSpec = _decodeAnswerSpec(
rawTask['answerSpec'],
fallbackAnswer: answer is String ? answer : '',
required: data['schemaVersion'] == 'lesson-2',
);
if (taskId is! String ||
taskId.length > 80 ||
skill is! String ||
!skills.contains(skill) ||
type is! String ||
!types.contains(type) ||
prompt is! String ||
prompt.trim().isEmpty ||
prompt.length > 260 ||
stimulus is! String ||
stimulus.trim().isEmpty ||
stimulus.length > 260 ||
answer is! String ||
answer.trim().isEmpty ||
answer.length > 160 ||
!_usesOnlyA0GeneratedWords(stimulus) ||
!_usesOnlyA0GeneratedWords(answer) ||
answerSpec == null ||
taskTargets == null ||
taskTargets.length != 1 ||
taskTargets.single != expectedTargetItemId) {
return null;
}
tasks.add(
GeneratedLessonTask(
taskId: taskId,
skill: skill,
type: type,
prompt: prompt.trim(),
stimulus: stimulus.trim(),
answer: answer.trim(),
targetItemIds: taskTargets,
answerSpec: answerSpec,
),
);
}
if (tasks.map((task) => task.skill).toSet().length != 4) return null;
return GeneratedLesson(
lessonId: lessonId,
revision: revision,
stageVersion: stageVersion,
abilityIds: abilityIds,
prerequisiteIds: prerequisites,
targetItemIds: targets,
receptiveChunks: chunks,
previewItemIds: preview,
estimatedMinutes: estimatedMinutes,
tasks: tasks,
);
} catch (_) {
return null;
}
}
String encodeGeneratedLesson(GeneratedLesson lesson) => jsonEncode({
'schemaVersion': 'lesson-2',
'lessonId': lesson.lessonId,
'revision': lesson.revision,
'stageVersion': lesson.stageVersion,
'source': 'aiGenerated',
'status': 'validated',
'abilityIds': lesson.abilityIds,
'prerequisiteIds': lesson.prerequisiteIds,
'targetItemIds': lesson.targetItemIds,
'receptiveChunks': lesson.receptiveChunks,
'newItemIds': const [],
'previewItemIds': lesson.previewItemIds,
'estimatedMinutes': lesson.estimatedMinutes,
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'targetItemIds': task.targetItemIds,
'answerSpec': _encodeAnswerSpec(task.localAnswerSpec),
},
)
.toList(),
});
Map<String, dynamic> _encodeAnswerSpec(GeneratedAnswerSpec spec) => {
'requiredAnyPhrases': spec.requiredAnyPhrases,
'acceptedAnswers': spec.acceptedAnswers,
'forbiddenPhrases': spec.forbiddenPhrases,
};
GeneratedAnswerSpec? _decodeAnswerSpec(
Object? value, {
required String fallbackAnswer,
required bool required,
}) {
if (value == null && !required && fallbackAnswer.trim().isNotEmpty) {
return GeneratedAnswerSpec.referenceOnly(fallbackAnswer);
}
if (value is! Map<String, dynamic> || value.length != 3) return null;
const keys = {'requiredAnyPhrases', 'acceptedAnswers', 'forbiddenPhrases'};
if (!value.keys.every(keys.contains)) return null;
final accepted = _stringList(value['acceptedAnswers'], max: 4);
final forbidden = _stringList(value['forbiddenPhrases'], max: 4);
final rawGroups = value['requiredAnyPhrases'];
if (accepted == null ||
accepted.isEmpty ||
forbidden == null ||
rawGroups is! List ||
rawGroups.isEmpty ||
rawGroups.length > 4) {
return null;
}
final groups = <List<String>>[];
for (final rawGroup in rawGroups) {
final group = _stringList(rawGroup, max: 4);
if (group == null || group.isEmpty) return null;
groups.add(group);
}
final phrases = [
...accepted,
...forbidden,
...groups.expand((group) => group),
];
if (phrases.any((phrase) => !_usesOnlyA0GeneratedWords(phrase))) return null;
final spec = GeneratedAnswerSpec(
requiredAnyPhrases: groups,
acceptedAnswers: accepted,
forbiddenPhrases: forbidden,
);
return matchesAdaptiveLessonAnswer(
GeneratedLessonTask(
taskId: 'validation',
skill: 'writing',
type: 'writeAnswer',
prompt: '验证',
stimulus: fallbackAnswer,
answer: fallbackAnswer,
targetItemIds: const ['A0-P01'],
answerSpec: spec,
),
fallbackAnswer,
)
? spec
: null;
}
/// Dynamic A0 reinforcement must not smuggle in a harder English word via a
/// stimulus or answer. Prompts may be Chinese; only learner-facing English is
/// constrained here. Names and places are fixed, non-personal demo values.
bool _usesOnlyA0GeneratedWords(String value) {
final words = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?")
.allMatches(value.toLowerCase())
.map((match) => match.group(0)!.replaceAll("'", ''));
const allowed = {
'i',
'im',
'am',
'my',
'name',
'is',
'what',
'your',
'nice',
'to',
'meet',
'you',
'how',
'do',
'spell',
'that',
'hello',
'hi',
'good',
'okay',
'tired',
'thanks',
'yes',
'no',
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'phone',
'number',
'it',
'a',
'book',
'pen',
'bag',
'key',
'where',
'from',
'this',
'mother',
'father',
'sister',
'brother',
'friend',
'day',
'today',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'time',
'oclock',
'like',
'water',
'coffee',
'tea',
'music',
'movies',
'please',
'say',
'again',
'speak',
'slowly',
'alex',
'mia',
'shen',
'hong',
'kong',
'beijing',
'shanghai',
};
return words.every(allowed.contains);
}
List<String>? _stringList(Object? value, {required int max}) {
if (value is! List || value.length > max) return null;
final result = <String>[];
for (final item in value) {
if (item is! String || item.trim().isEmpty || item.length > 120) {
return null;
}
result.add(item.trim());
}
return result.toSet().length == result.length ? result : null;
}
/// Validates the small feedback object before any AI wording reaches a
/// learner. The app treats an uncertain or malformed response as no feedback,
/// never as a language error.
WritingAiFeedback? decodeWritingAiFeedback(
String raw, {
required String expectedLessonId,
}) {
if (raw.length > 1800) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 6) return null;
if (data['schemaVersion'] != 'writing-feedback-1' ||
data['lessonId'] != expectedLessonId) {
return null;
}
final verdict = data['verdict'];
final feedback = data['feedback'];
final suggestion = data['suggestion'];
final missing = data['missing'];
if (verdict is! String ||
!const {'accepted', 'rewrite', 'uncertain'}.contains(verdict) ||
feedback is! String ||
feedback.trim().isEmpty ||
feedback.length > 240 ||
(suggestion != null &&
(suggestion is! String ||
suggestion.trim().isEmpty ||
suggestion.length > 180)) ||
missing is! List ||
missing.length > 3) {
return null;
}
final normalizedMissing = <String>[];
for (final item in missing) {
if (item is! String || item.trim().isEmpty || item.length > 80) {
return null;
}
normalizedMissing.add(item.trim());
}
return WritingAiFeedback(
verdict: verdict,
feedback: feedback.trim(),
suggestion: suggestion?.trim(),
missing: normalizedMissing,
);
} catch (_) {
return null;
}
}
+513
View File
@@ -0,0 +1,513 @@
import 'dart:io';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
/// Durable local storage for learner-owned state. Evidence, mastery, reviews,
/// sessions, assessments and caches are persisted as separate SQLite entities.
/// A legacy versioned snapshot may be read as a migration/recovery fallback,
/// but normal writes never create or replace one. No provider key, audio bytes,
/// or cloud data is stored here.
class LocalSnapshotStore {
LocalSnapshotStore._();
LocalSnapshotStore.forTesting(QueryExecutor executor) : _executor = executor;
static final instance = LocalSnapshotStore._();
QueryExecutor? _executor;
Future<QueryExecutor>? _opening;
Future<void>? _initializing;
Future<QueryExecutor> _open() => _opening ??= _create();
Future<QueryExecutor> _create() async {
final directory = await getApplicationSupportDirectory();
final database = NativeDatabase.createInBackground(
File('${directory.path}/kouyu_learning.sqlite'),
);
_executor = database;
await _ensureSchema(database);
return database;
}
Future<void> _ensureSchema(QueryExecutor executor) =>
_initializing ??= _openAndCreateSchema(executor);
Future<void> _openAndCreateSchema(QueryExecutor executor) async {
await executor.ensureOpen(_snapshotStoreUser);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learning_snapshots (
snapshot_id TEXT PRIMARY KEY NOT NULL,
schema_version INTEGER NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS attempt_evidence (
attempt_id TEXT PRIMARY KEY NOT NULL, item_id TEXT NOT NULL,
task_id TEXT NOT NULL, skill TEXT NOT NULL, input_mode TEXT NOT NULL,
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
transcript_edited INTEGER NOT NULL DEFAULT 0
)
''');
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'original_transcript',
definition: 'TEXT',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_confirmed',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_edited',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS mastery_items (
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
checkpoint INTEGER NOT NULL, needs_review INTEGER NOT NULL, first_taught_at TEXT,
payload TEXT NOT NULL DEFAULT '{}'
)
''');
await _ensureColumn(
executor,
table: 'mastery_items',
name: 'payload',
definition: "TEXT NOT NULL DEFAULT '{}'",
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS review_items (
item_id TEXT PRIMARY KEY NOT NULL, due_at TEXT NOT NULL, attempts INTEGER NOT NULL,
successful_reviews INTEGER NOT NULL, variant_index INTEGER NOT NULL, payload TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS study_sessions (
session_id TEXT PRIMARY KEY NOT NULL, session_type TEXT NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS temporary_lexicon_entries (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
definition TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learner_profiles (
profile_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS lesson_progress (
progress_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS assessment_records (
pack_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
completed_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS generated_content_cache (
cache_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
}
Future<void> _ensureColumn(
QueryExecutor executor, {
required String table,
required String name,
required String definition,
}) async {
final columns = await executor.runSelect(
'PRAGMA table_info($table)',
const [],
);
if (columns.any((column) => column['name'] == name)) return;
await executor.runCustom('ALTER TABLE $table ADD COLUMN $name $definition');
}
Future<String?> read() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final canonical = await _readCanonical(executor);
if (canonical != null) return jsonEncode(canonical);
final rows = await executor.runSelect(
'SELECT payload FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
return rows.isEmpty ? null : rows.single['payload'] as String?;
}
/// Rebuilds the application state from normalized local entities. The old
/// snapshot is intentionally only a migration/recovery fallback: once a
/// profile exists, a partially written or stale snapshot cannot win.
Future<Map<String, dynamic>?> _readCanonical(QueryExecutor executor) async {
final profileRows = await executor.runSelect(
'SELECT payload FROM learner_profiles WHERE profile_id = ?',
const ['current'],
);
final lessonRows = await executor.runSelect(
'SELECT payload FROM lesson_progress WHERE progress_id = ?',
const ['current'],
);
if (profileRows.isEmpty || lessonRows.isEmpty) return null;
try {
final result = <String, dynamic>{
..._decodeObject(profileRows.single['payload']),
..._decodeObject(lessonRows.single['payload']),
};
final contentRows = await executor.runSelect(
'SELECT payload FROM generated_content_cache WHERE cache_id = ?',
const ['adaptive'],
);
if (contentRows.isNotEmpty) {
result.addAll(_decodeObject(contentRows.single['payload']));
}
result['attemptEvidence'] =
(await executor.runSelect(
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
created_at, raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
FROM attempt_evidence ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'id': row['attempt_id'],
'itemId': row['item_id'],
'taskId': row['task_id'],
'skill': row['skill'],
'inputMode': row['input_mode'],
'outcome': row['outcome'],
'createdAt': row['created_at'],
'rawAnswer': row['raw_answer'],
'recordingPath': row['recording_path'],
'assisted': row['assisted'] == 1,
'variantIndex': row['variant_index'],
'originalTranscript': row['original_transcript'],
'transcriptConfirmed': row['transcript_confirmed'] == 1,
'transcriptEdited': row['transcript_edited'] == 1,
},
)
.toList();
result['mastery'] = (await executor.runSelect(
'SELECT payload FROM mastery_items ORDER BY item_id',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['reviews'] = (await executor.runSelect(
'SELECT payload FROM review_items ORDER BY due_at',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['temporaryLexicon'] =
(await executor.runSelect(
'''SELECT query, definition, provider, model, created_at
FROM temporary_lexicon_entries ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'definition': row['definition'],
'provider': row['provider'],
'model': row['model'],
'createdAt': row['created_at'],
},
)
.toList();
result['assessments'] = (await executor.runSelect(
'SELECT payload FROM assessment_records ORDER BY completed_at',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
final sessions = await executor.runSelect(
'SELECT session_type, payload FROM study_sessions',
const [],
);
for (final session in sessions) {
if (session['session_type'] == 'assessment') {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
} catch (_) {
// Keep a previous version usable if an interrupted legacy migration
// leaves any individual canonical payload unreadable.
return null;
}
}
Map<String, dynamic> _decodeObject(Object? raw) {
if (raw is! String) return const {};
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : const {};
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
// `learning_snapshots` belongs to the old storage format. Do not keep
// mirroring the complete in-memory state into it: canonical state is the
// normalized entity set below. Existing snapshots remain readable only
// until a successful entity restore makes them unnecessary.
await _writeEntities(transaction, payload);
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
Future<void> _writeEntities(QueryExecutor executor, String payload) async {
final data = jsonDecode(payload) as Map<String, dynamic>;
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await executor.runCustom('DELETE FROM $table');
}
for (final entry in (data['attemptEvidence'] as List? ?? const [])) {
if (entry is! Map) continue;
await executor.runCustom(
'''INSERT INTO attempt_evidence(
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
[
entry['id'],
entry['itemId'],
entry['taskId'],
entry['skill'],
entry['inputMode'],
entry['outcome'],
entry['createdAt'],
entry['rawAnswer'],
entry['recordingPath'],
entry['assisted'] == true ? 1 : 0,
entry['variantIndex'] ?? 0,
entry['originalTranscript'],
entry['transcriptConfirmed'] == true ? 1 : 0,
entry['transcriptEdited'] == true ? 1 : 0,
],
);
}
for (final item in (data['mastery'] as List? ?? const [])) {
if (item is! Map) continue;
await executor.runCustom(
'''INSERT INTO mastery_items(
item_id, label, status, checkpoint, needs_review, first_taught_at, payload
) VALUES (?, ?, ?, ?, ?, ?, ?)''',
[
item['id'],
item['label'],
item['status'],
item['checkpoint'] ?? 0,
item['needsReview'] == true ? 1 : 0,
item['firstTaughtAt'],
jsonEncode(item),
],
);
}
for (final item in (data['reviews'] as List? ?? const [])) {
if (item is! Map) continue;
await executor
.runCustom('INSERT INTO review_items VALUES (?, ?, ?, ?, ?, ?)', [
item['id'],
item['dueAt'],
item['attempts'] ?? 0,
item['successfulReviews'] ?? 0,
item['variantIndex'] ?? 0,
jsonEncode(item),
]);
}
final sessions = {
'lesson': {
'step': data['lessonStep'],
'lessonId': data['activeLessonId'],
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
await executor
.runCustom('INSERT INTO study_sessions VALUES (?, ?, ?, ?)', [
entry.key,
entry.key,
jsonEncode(entry.value),
DateTime.now().toUtc().toIso8601String(),
]);
}
final now = DateTime.now().toUtc().toIso8601String();
await executor.runCustom('INSERT INTO learner_profiles VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'onboardingComplete',
'goal',
'placement',
'dailyMinutes',
'showChineseHints',
'keepRecordings',
'aiEndpoint',
'aiModel',
'aiProvider',
'reportedAiVariantKeys',
'reportedAdaptiveLessonIds',
])
key: data[key],
}),
now,
]);
await executor.runCustom('INSERT INTO lesson_progress VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'lessonStep',
'previewIndex',
'completedLessons',
'activeLessonId',
'completedLessonIds',
'completedSegmentIds',
'activeSegmentIndexes',
'lessonListeningComplete',
'lessonSpeakingComplete',
'lessonReadingComplete',
'lessonWritingComplete',
'lessonDialogueComplete',
'independentAttemptComplete',
'independentAttemptAssisted',
'independentAttemptSpoken',
'lessonWritingDraft',
'independentAttemptDraft',
])
key: data[key],
}),
now,
]);
for (final assessment in (data['assessments'] as List? ?? const [])) {
if (assessment is! Map || assessment['packId'] is! String) continue;
await executor
.runCustom('INSERT INTO assessment_records VALUES (?, ?, ?)', [
assessment['packId'],
jsonEncode(assessment),
assessment['completedAt'] ?? now,
]);
}
await executor.runCustom(
'INSERT INTO generated_content_cache VALUES (?, ?, ?)',
[
'adaptive',
jsonEncode({
for (final key in const [
'cachedAdaptiveLessonRaw',
'cachedAdaptiveLessonAuditedAt',
'cachedAdaptiveLessonAuditor',
'adaptiveLessonDraftId',
'adaptiveLessonDraftIndex',
'adaptiveLessonDraftAnswer',
'adaptiveLessonDraftReferenceShown',
'adaptiveLessonDraftUsedVoice',
'adaptiveLessonDraftTranscriptEdited',
'adaptiveLessonDraftTranscriptConfirmed',
'adaptiveLessonDraftOriginalTranscript',
'adaptiveLessonDraftRecordingPath',
])
key: data[key],
}),
now,
],
);
for (final entry in (data['temporaryLexicon'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final definition = entry['definition'] as String? ?? '';
if (key.isEmpty || definition.trim().isEmpty) continue;
await executor.runCustom(
'INSERT INTO temporary_lexicon_entries VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
definition,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
await transaction.runCustom(
'DELETE FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await transaction.runCustom('DELETE FROM $table');
}
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
}
final _snapshotStoreUser = _SnapshotStoreUser();
class _SnapshotStoreUser implements QueryExecutorUser {
@override
int get schemaVersion => 1;
@override
Future<void> beforeOpen(
QueryExecutor executor,
OpeningDetails details,
) async {}
}
+318
View File
@@ -0,0 +1,318 @@
enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { home, learn, dialogue, review, progress }
enum LessonStep {
preview,
listening,
speaking,
reading,
writing,
dialogue,
independent,
complete,
}
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
enum EvidenceKind {
exposure,
assisted,
independentSuccess,
languageError,
pending,
}
class AttemptEvidence {
const AttemptEvidence({
required this.id,
required this.itemId,
required this.taskId,
required this.skill,
required this.inputMode,
required this.outcome,
required this.createdAt,
this.rawAnswer,
this.recordingPath,
this.assisted = false,
this.variantIndex = 0,
this.originalTranscript,
this.transcriptConfirmed = false,
this.transcriptEdited = false,
});
final String id;
final String itemId;
final String taskId;
final String skill;
final String inputMode;
final EvidenceKind outcome;
final DateTime createdAt;
final String? rawAnswer;
/// Local-only file reference. It is absent unless the learner opted to keep
/// recordings; no path or audio is sent to an AI provider.
final String? recordingPath;
final bool assisted;
final int variantIndex;
/// The device STT result before the learner edits it. This local-only field
/// distinguishes confirmed spoken input from a subsequent typed rewrite.
final String? originalTranscript;
final bool transcriptConfirmed;
final bool transcriptEdited;
}
enum AiProviderType { mock, gemini, openAi, compatible }
enum AssessmentSkill { listening, speaking, reading, writing }
enum ContentStatus { draft, validated, approved, rejected }
enum ContentSource { builtInOriginal, aiGenerated, importedReference }
/// A validated, bounded suggestion from an AI conversation provider.
/// The client still owns task completion and mastery decisions.
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.feedback,
});
final String reply;
final Map<String, String> slots;
final List<String> evidence;
final bool suggestsComplete;
final String? feedback;
}
/// A bounded suggestion only. It never changes completion, mastery, or a
/// stage result; those decisions remain on-device and evidence-based.
class WritingAiFeedback {
const WritingAiFeedback({
required this.verdict,
required this.feedback,
required this.suggestion,
required this.missing,
});
/// `accepted`, `rewrite`, or `uncertain`.
final String verdict;
final String feedback;
final String? suggestion;
final List<String> missing;
}
class AssessmentRecord {
const AssessmentRecord({
required this.packId,
required this.completedAt,
required this.results,
this.pendingSkills = const {},
});
final String packId;
final DateTime completedAt;
final Map<AssessmentSkill, bool> results;
final Set<AssessmentSkill> pendingSkills;
bool get passed =>
AssessmentSkill.values.every((skill) => results[skill] == true);
List<AssessmentSkill> get failedSkills => AssessmentSkill.values
.where(
(skill) => results[skill] != true && !pendingSkills.contains(skill),
)
.toList();
}
class AssessmentDraft {
const AssessmentDraft({
required this.packId,
required this.taskIndex,
required this.results,
});
final String packId;
final int taskIndex;
final Map<String, bool> results;
}
class DialogueDraft {
const DialogueDraft({
required this.lessonId,
required this.stage,
required this.turns,
required this.usedHelp,
});
final String lessonId;
final int stage;
final List<DialogueTurn> turns;
final bool usedHelp;
}
class LessonSummary {
const LessonSummary({
required this.number,
required this.title,
required this.outcome,
this.isComplete = false,
this.isCurrent = false,
});
final int number;
final String title;
final String outcome;
final bool isComplete;
final bool isCurrent;
}
class VocabularyItem {
const VocabularyItem({
required this.id,
required this.word,
required this.meaning,
required this.example,
required this.exampleMeaning,
this.ipa,
});
final String id;
final String word;
final String meaning;
final String example;
final String exampleMeaning;
final String? ipa;
}
/// A non-core explanation returned by a configured text provider for a word
/// or phrase that is not present in the reviewed course lexicon. These
/// entries deliberately never participate in review scheduling or mastery.
class TemporaryLexiconEntry {
const TemporaryLexiconEntry({
required this.query,
required this.definition,
required this.provider,
required this.model,
required this.createdAt,
});
final String query;
final String definition;
final String provider;
final String model;
final DateTime createdAt;
}
class ReviewItem {
const ReviewItem({
required this.id,
required this.target,
required this.prompt,
required this.hint,
required this.dueAt,
required this.skill,
this.attempts = 0,
this.successfulReviews = 0,
this.variantIndex = 0,
this.lastProgressedAt,
this.isAiGenerated = false,
});
final String id;
final String target;
final String prompt;
final String hint;
final DateTime dueAt;
final String skill;
final int attempts;
final int successfulReviews;
final int variantIndex;
final DateTime? lastProgressedAt;
final bool isAiGenerated;
ReviewItem copyWith({
DateTime? dueAt,
int? attempts,
int? successfulReviews,
int? variantIndex,
String? prompt,
String? hint,
String? skill,
DateTime? lastProgressedAt,
bool? isAiGenerated,
}) => ReviewItem(
id: id,
target: target,
prompt: prompt ?? this.prompt,
hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill,
attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex,
lastProgressedAt: lastProgressedAt ?? this.lastProgressedAt,
isAiGenerated: isAiGenerated ?? this.isAiGenerated,
);
}
class MasteryItem {
const MasteryItem({
required this.id,
required this.label,
required this.status,
required this.evidence,
this.needsReview = false,
this.checkpoint = 0,
this.firstTaughtAt,
});
final String id;
final String label;
final MasteryStatus status;
final List<EvidenceKind> evidence;
final bool needsReview;
final int checkpoint;
final DateTime? firstTaughtAt;
MasteryItem copyWith({
MasteryStatus? status,
List<EvidenceKind>? evidence,
bool? needsReview,
int? checkpoint,
DateTime? firstTaughtAt,
}) => MasteryItem(
id: id,
label: label,
status: status ?? this.status,
evidence: evidence ?? this.evidence,
needsReview: needsReview ?? this.needsReview,
checkpoint: checkpoint ?? this.checkpoint,
firstTaughtAt: firstTaughtAt ?? this.firstTaughtAt,
);
}
class DialogueTurn {
const DialogueTurn({required this.text, required this.isLearner});
final String text;
final bool isLearner;
}
/// Ephemeral presentation data for a completed controlled dialogue. The
/// related recap review is separately persisted in AppState.
class DialogueSummaryData {
const DialogueSummaryData({
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
}
@@ -0,0 +1,93 @@
import 'models.dart';
class ReviewCheckResult {
const ReviewCheckResult({required this.complete, required this.message});
final bool complete;
final String message;
}
/// Checks only the minimum information a learner needs to produce in an A0
/// review. It intentionally accepts common contractions and variable slot
/// values instead of comparing an answer with one fixed sentence.
class ReviewFeedback {
const ReviewFeedback._();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(token);
bool phrase(String value) => text.contains(value);
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
final numberWords = RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length;
final complete = switch (item.id) {
'A0-P01' => introduction && tokens.length >= 2,
'A0-P02' => (phrase("what's your name") || phrase('what is your name')),
'A0-P03' => phrase('nice to meet you'),
'A0-P04' => phrase('how do you spell'),
'A0-P05' => phrase('how are you'),
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
'A0-P07' =>
phrase("what's your phone number") ||
phrase('what is your phone number'),
'A0-P08' =>
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
'A0-P11' => phrase('where are you from'),
'A0-P12' => introduction && has('from') && tokens.length >= 3,
'A0-P13' => phrase('this is my') && tokens.length >= 4,
'A0-P14' => phrase('what day is it'),
'A0-P15' =>
itIs &&
hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') => tokens.contains(
item.target.toLowerCase(),
),
_ => tokens.length >= 2,
};
return ReviewCheckResult(
complete: complete,
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
);
}
static String _hint(String id) => switch (id) {
'A0-P01' => '用 Im / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:Whats your name?',
'A0-P04' => '问对方怎样拼写:How do you spell …?',
'A0-P08' => '说出至少三个英文数字,也可用 My number is … 开头。',
'A0-P10' => '用 It’s / It is 加上一个物品。',
'A0-P12' => '用 I’m from … 说出一个地点。',
'A0-P17' => '用 It’s … o’clock 说整点时间。',
'A0-P18' => '用 I like … 说一项喜好。',
_ => '再补充本次目标中的关键英文词或句型。',
};
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:speech_to_text/speech_to_text.dart';
/// Device speech facilities are optional. Callers must keep a text fallback
/// because availability depends on device language packs and permissions.
class VoiceService {
VoiceService._();
static final instance = VoiceService._();
final FlutterTts _tts = FlutterTts();
final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
bool _speechReady = false;
Future<void> speak(String text, {bool slow = false}) async {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
}
Future<void> stopSpeaking() => _tts.stop();
Future<bool> startRecording() async {
if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) await recordings.create(recursive: true);
final timestamp = DateTime.now().microsecondsSinceEpoch;
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: '${recordings.path}/practice_$timestamp.m4a',
);
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<void> playRecording(String path) async {
await _player.stop();
await _player.play(DeviceFileSource(path));
}
Future<void> stopRecordingPlayback() => _player.stop();
Future<void> deleteRecording(String? path) async {
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) await file.delete();
}
Future<int> deleteAllRecordings() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return 0;
final files = await recordings
.list()
.where((item) => item is File)
.toList();
for (final file in files.cast<File>()) {
await file.delete();
}
return files.length;
}
Future<List<String>> listRecordingPaths() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return const [];
final files = await recordings
.list()
.where((item) => item is File && item.path.endsWith('.m4a'))
.cast<File>()
.toList();
files.sort((left, right) => right.path.compareTo(left.path));
return files.map((file) => file.path).toList();
}
Future<void> disposeRecording() async {
await _recorder.dispose();
await _player.dispose();
}
Future<bool> initializeSpeech() async {
_speechReady = await _stt.initialize();
return _speechReady;
}
Future<bool> startListening(
void Function(String text, bool finalResult) onResult,
) async {
if (!_speechReady && !await initializeSpeech()) {
return false;
}
await _stt.listen(
onResult: (result) =>
onResult(result.recognizedWords, result.finalResult),
listenOptions: SpeechListenOptions(
localeId: 'en_US',
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 4),
),
);
return true;
}
Future<void> stopListening() => _stt.stop();
bool get isListening => _stt.isListening;
}
@@ -0,0 +1,112 @@
class WritingCheckResult {
const WritingCheckResult({required this.complete, required this.message});
final bool complete;
final String message;
}
/// A deliberately small offline checker for teaching tasks. It checks only
/// the essential A0 information, accepts variable names/places, and never
/// claims to grade pronunciation or nuanced grammar.
class WritingFeedback {
const WritingFeedback._();
static WritingCheckResult check(
String lessonId,
String input, {
String? segmentId,
}) {
final text = input.toLowerCase().replaceAll('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool hasPhrase(String phrase) => text.contains(phrase);
bool hasAny(Iterable<String> choices) => choices.any(has);
final hasIntroduction =
hasPhrase("i'm") || hasPhrase('i am') || hasPhrase('my name is');
final hasItIs = hasPhrase("it's") || hasPhrase('it is');
final complete = switch (segmentId) {
'a0-04-a' => hasAll(words, ['zero', 'one', 'two', 'three']),
'a0-04-b' => hasAll(words, ['six', 'seven', 'eight']),
'a0-04-c' =>
RegExp(
r'\b(one|two|three|four|five|six|seven|eight|nine|zero)\b',
).allMatches(text).length >=
3,
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
'a0-08-b' =>
hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']),
'a0-08-c' => hasItIs && (has('oclock') || text.contains("o'clock")),
_ => switch (lessonId) {
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
'a0-02' =>
(hasPhrase('my name') || hasIntroduction) &&
RegExp(r'[a-z](?:[ -]?[a-z]){2,}').hasMatch(text),
'a0-03' => hasIntroduction && hasAny(['good', 'okay', 'tired']),
'a0-04' =>
RegExp(
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
).allMatches(text).length >=
3,
'a0-05' => hasItIs && hasAny(['book', 'pen', 'bag', 'key']),
'a0-06' => hasIntroduction && has('from') && words.length >= 3,
'a0-07' =>
hasPhrase('this is my') &&
hasAny(['mother', 'father', 'sister', 'brother', 'friend']),
'a0-08' =>
hasItIs &&
(hasAny([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]) ||
has('clock')),
'a0-09' => hasIntroduction && has('like') && words.length >= 3,
'a0-10' => hasIntroduction && has('from') && has('like'),
_ => words.length >= 2,
},
};
if (complete) {
return const WritingCheckResult(
complete: true,
message: '这句已经表达完整。继续在对话里用一次吧。',
);
}
return WritingCheckResult(
complete: false,
message: _hintFor(segmentId ?? lessonId),
);
}
static bool hasAll(Set<String> words, Iterable<String> required) =>
required.every(words.contains);
static String _hintFor(String lessonId) => switch (lessonId) {
'a0-04-a' => '请写出 zero、one、two、three 四个数字。',
'a0-04-b' => '请写出 six、seven、eight 三个数字。',
'a0-04-c' => '用英文一个一个写出三个数字,例如 one-three-nine。',
'a0-08-a' => '用 It is / Its 加 Monday、Tuesday 或 Wednesday。',
'a0-08-b' => '用 It is / Its 加 Thursday、Friday、Saturday 或 Sunday。',
'a0-08-c' => '用 It is / Its 加数字和 oclock,例如 Its three oclock。',
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
'a0-02' => '写出姓名,并把至少三个字母用空格或连字符拼出来。',
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',
'a0-06' => '用 I’m from … 写成一个完整句子。',
'a0-07' => '用 This is my … 介绍一位家人或朋友。',
'a0-08' => '用 It’s … 写一个星期或整点时间。',
'a0-09' => '用 I like … 写出一种喜好。',
'a0-10' => '分别写姓名、来自哪里和一项喜好三部分。',
_ => '再补充一个完整英文句子。',
};
}
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/assessment_bank.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class AssessmentPreparationPage extends StatefulWidget {
const AssessmentPreparationPage({
super.key,
required this.state,
required this.pack,
required this.onStart,
required this.onBack,
});
final AppState state;
final AssessmentPack pack;
final VoidCallback onStart;
final VoidCallback onBack;
@override
State<AssessmentPreparationPage> createState() =>
_AssessmentPreparationPageState();
}
class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
bool? microphoneReady;
bool checkingMicrophone = false;
Future<void> _checkMicrophone() async {
setState(() => checkingMicrophone = true);
final ready = await VoiceService.instance.initializeSpeech();
if (!mounted) return;
setState(() {
microphoneReady = ready;
checkingMicrophone = false;
});
}
@override
Widget build(BuildContext context) {
final draft = widget.state.assessmentDraft;
final canResume = draft != null && draft.packId == widget.pack.id;
final pack = widget.pack;
return AppPage(
appBar: AppBar(title: const Text('评估准备')),
child: SpacedColumn(
children: [
Eyebrow('A0 阶段评估 · ${pack.id}'),
Text('先确认评估方式', style: Theme.of(context).textTheme.headlineMedium),
const Text('这不是日常练习:它用于确认你能在没有提示时完成基础交流。'),
SectionCard(
tint: AppColors.softGreen,
child: Text(
'本题组包含:听力 ${pack.forSkill(AssessmentSkill.listening).length} 题、'
'阅读 ${pack.forSkill(AssessmentSkill.reading).length} 题、'
'写作 ${pack.forSkill(AssessmentSkill.writing).length} 题、'
'口语 ${pack.forSkill(AssessmentSkill.speaking).length} 题。',
),
),
SectionCard(
child: Text(
'当前核心项:${widget.state.coreUsableCount} 项可用,'
'${widget.state.coreMasteredCount} 项已掌握。\n'
'这些数字帮助你判断准备程度,但不会替代本次评估。',
),
),
const SectionCard(
tint: AppColors.warm,
child: Text(
'评估规则\n'
'• 不提供翻译、查词、句框或答案。\n'
'• 听力题必须先播放音频。\n'
'• 口语题必须用麦克风回答,且不可编辑转写。\n'
'• 麦克风不可用时可保留口语待评估;不会算作语言错误。',
),
),
SecondaryButton(
label: checkingMicrophone
? '正在检查麦克风…'
: microphoneReady == true
? '麦克风可用'
: microphoneReady == false
? '麦克风暂不可用,重新检查'
: '检查麦克风',
onPressed: checkingMicrophone ? null : _checkMicrophone,
),
if (canResume)
SectionCard(
tint: AppColors.warm,
child: Text('会从上次中断的第 ${draft.taskIndex + 1} 题继续,已完成答案会保留。'),
),
PrimaryButton(
label: canResume ? '继续评估' : '开始评估',
onPressed: widget.onStart,
),
SecondaryButton(label: '返回阶段进度', onPressed: widget.onBack),
],
),
);
}
}
class AssessmentPage extends StatefulWidget {
const AssessmentPage({
super.key,
required this.state,
required this.pack,
required this.onFinished,
required this.onStartReplacement,
});
final AppState state;
final AssessmentPack pack;
final VoidCallback onFinished;
final ValueChanged<AssessmentPack> onStartReplacement;
@override
State<AssessmentPage> createState() => _AssessmentPageState();
}
class _AssessmentPageState extends State<AssessmentPage> {
final controller = TextEditingController();
final Map<String, bool> results = {};
int index = 0;
bool usedMic = false;
bool transcriptEdited = false;
String lastTranscript = '';
bool listening = false;
bool audioPlayed = false;
bool speakingUnavailable = false;
AssessmentRecord? completedRecord;
AssessmentTask get task => widget.pack.tasks[index];
@override
void initState() {
super.initState();
final draft = widget.state.assessmentDraft;
if (draft != null &&
draft.packId == widget.pack.id &&
draft.taskIndex < widget.pack.tasks.length) {
index = draft.taskIndex;
results.addAll(draft.results);
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
Future<void> _play() async {
await VoiceService.instance.speak(task.audio!);
if (mounted) setState(() => audioPlayed = true);
}
Future<void> _mic() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (mounted) {
setState(() {
controller.text = text;
usedMic = true;
lastTranscript = text;
});
}
});
if (mounted) {
setState(() {
listening = ready;
speakingUnavailable = !ready;
});
}
if (!ready && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
);
}
}
bool _openCorrect() {
return checkOpenAssessmentAnswer(task, controller.text);
}
void _submit([int? answer]) {
if (answer == null &&
task.skill == AssessmentSkill.speaking &&
(!usedMic || transcriptEdited)) {
setState(() => speakingUnavailable = true);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请使用未编辑的语音转写,或将口语保留为待评估。')));
return;
}
final correct = answer != null
? answer == task.answerIndex &&
(task.skill != AssessmentSkill.listening || audioPlayed)
: _openCorrect() &&
(task.skill != AssessmentSkill.speaking ||
(usedMic && !transcriptEdited));
results[task.id] = correct;
widget.state.recordAssessmentAttempt(
taskId: task.id,
skill: _skillLabel(task.skill),
correct: correct,
rawAnswer: answer == null ? controller.text.trim() : task.choices[answer],
spoken: task.skill == AssessmentSkill.speaking,
);
if (index + 1 < widget.pack.tasks.length) {
setState(() {
index++;
controller.clear();
usedMic = false;
transcriptEdited = false;
lastTranscript = '';
listening = false;
audioPlayed = false;
});
widget.state.saveAssessmentDraft(
AssessmentDraft(
packId: widget.pack.id,
taskIndex: index,
results: results,
),
);
} else {
_finish();
}
}
void _markSpeakingPending() {
for (final speaking in widget.pack.forSkill(AssessmentSkill.speaking)) {
if (!results.containsKey(speaking.id)) {
results[speaking.id] = false;
widget.state.recordAssessmentPending(
taskId: speaking.id,
skill: _skillLabel(AssessmentSkill.speaking),
reason: '设备麦克风或语音识别不可用,等待补测。',
);
}
}
_finish(pendingSkills: const {AssessmentSkill.speaking});
}
void _finish({Set<AssessmentSkill> pendingSkills = const {}}) {
bool passed(AssessmentSkill skill) {
final items = widget.pack.forSkill(skill);
final score = items.where((item) => results[item.id] == true).length;
if (skill == AssessmentSkill.listening) {
return score >= 8 &&
(results[items[3].id] == true || results[items[4].id] == true) &&
results[items[8].id] == true;
}
if (skill == AssessmentSkill.reading) {
return score >= 4 && results[items[1].id] == true;
}
if (skill == AssessmentSkill.writing) {
return score >= 4 &&
items.take(3).every((item) => results[item.id] == true);
}
return score == items.length;
}
final record = AssessmentRecord(
packId: widget.pack.id,
completedAt: DateTime.now(),
results: {
for (final skill in AssessmentSkill.values) skill: passed(skill),
},
pendingSkills: pendingSkills,
);
final merged = widget.state.recordAssessment(record);
widget.state.clearAssessmentDraft();
setState(() => completedRecord = merged);
}
@override
Widget build(BuildContext context) {
final record = completedRecord;
if (record != null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('评估结果已保存'),
Text(
record.passed
? '本题组四技能通过。'
: record.pendingSkills.isNotEmpty
? '已保存完成的技能;有技能待评估。'
: '先补练,再使用替换题补测。',
style: Theme.of(context).textTheme.headlineMedium,
),
if (record.passed)
const SectionCard(
child: Text('已通过的技能在本题组 7 天有效窗口内保留。第二题组仍须使用不同题面。'),
),
if (!record.passed) ...[
if (record.pendingSkills.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: Text(
'待评估:${record.pendingSkills.map(_skillLabel).join('')}。技术问题不会计为语言错误。',
),
),
if (record.failedSkills.isNotEmpty) const Text('建议补练:'),
for (final skill in record.failedSkills)
SectionCard(child: Text(_remediation(skill))),
],
if (!record.passed && replacementFor(widget.pack.id) != null)
SecondaryButton(
label: '开始替换题补测',
onPressed: () =>
widget.onStartReplacement(replacementFor(widget.pack.id)!),
),
PrimaryButton(label: '回到阶段进度', onPressed: widget.onFinished),
],
),
);
}
return AppPage(
appBar: AppBar(
title: Text(
'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}',
),
),
child: SpacedColumn(
children: [
Text(
_skillLabel(task.skill),
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('评估中不提供翻译、句框或答案。需要帮助请退出后先做补练。'),
if (task.skill == AssessmentSkill.listening) ...[
PrimaryButton(
label: audioPlayed ? '再播放一次' : '播放音频',
onPressed: _play,
),
const Text('请根据听到的内容选择答案。'),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: audioPlayed ? () => _submit(i) : null,
child: Text(task.choices[i]),
),
] else if (task.skill == AssessmentSkill.reading) ...[
SectionCard(
child: Text(
task.prompt,
style: const TextStyle(fontSize: 17, height: 1.6),
),
),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: () => _submit(i),
child: Text(task.choices[i]),
),
] else ...[
SectionCard(
child: Text(task.prompt, style: const TextStyle(fontSize: 18)),
),
TextField(
controller: controller,
onChanged: (value) => setState(() {
if (task.skill == AssessmentSkill.speaking &&
usedMic &&
value != lastTranscript) {
transcriptEdited = true;
}
}),
minLines: 2,
decoration: InputDecoration(
hintText: task.skill == AssessmentSkill.speaking
? '使用麦克风说出答案;文字仅作待评估记录'
: '输入英文答案',
border: const OutlineInputBorder(),
),
),
if (task.skill == AssessmentSkill.speaking)
SecondaryButton(
label: listening ? '停止录音' : '使用麦克风回答',
onPressed: _mic,
),
if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
SecondaryButton(
label: '将口语保留为待评估',
onPressed: _markSpeakingPending,
),
PrimaryButton(
label: '提交',
onPressed: controller.text.trim().isEmpty ? null : _submit,
),
],
],
),
);
}
String _remediation(AssessmentSkill skill) => switch (skill) {
AssessmentSkill.listening => '听力:回到数字、星期/时间和场景听辨复习;下一次会使用不同音频。',
AssessmentSkill.speaking => '口语:练姓名、号码、物品、家人、星期、整点和请求重复;确认未编辑转写后再补测。',
AssessmentSkill.reading => '阅读:复习人物、地点、数字和时间信息定位,再阅读不同短对话。',
AssessmentSkill.writing => '写作:分别练完整的姓名、地点、喜好、物品、星期/时间句,不使用句框。',
};
String _skillLabel(AssessmentSkill skill) => switch (skill) {
AssessmentSkill.listening => '听力',
AssessmentSkill.speaking => '口语',
AssessmentSkill.reading => '阅读',
AssessmentSkill.writing => '写作',
};
}
@@ -0,0 +1,680 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart});
final VoidCallback onStart;
@override
Widget build(BuildContext context) => AppPage(
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答轮,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'初次见面',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text('A0', style: TextStyle(color: AppColors.green)),
],
),
Text(
'介绍姓名、地点、状态或喜好,并反问对方',
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(label: '开始对话', onPressed: onStart),
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
],
),
);
}
class _LockedScene extends StatelessWidget {
const _LockedScene({required this.title, required this.note});
final String title;
final String note;
@override
Widget build(BuildContext context) => SectionCard(
child: Row(
children: [
const Icon(Icons.lock_outline, color: AppColors.muted),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class DialoguePage extends StatefulWidget {
const DialoguePage({
super.key,
required this.state,
required this.onFinished,
this.isLessonDialogue = false,
});
final AppState state;
final ValueChanged<DialogueSummaryData?> onFinished;
final bool isLessonDialogue;
@override
State<DialoguePage> createState() => _DialoguePageState();
}
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
String? hint;
bool listening = false;
bool recording = false;
bool playingRecording = false;
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? recordingPath;
bool waitingForReply = false;
String? validationError;
LessonDialogue get script => widget.isLessonDialogue
? dialogueBySegmentId(
lessonById(widget.state.activeLessonId)
.segments[widget.state.activeSegmentIndexFor(
widget.state.activeLessonId,
)]
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
);
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
stage = draft.stage;
usedHelp = draft.usedHelp;
turns.addAll(draft.turns);
} else {
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
}
}
@override
void dispose() {
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose();
super.dispose();
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
return;
}
widget.state.recordDialogueAttempt(
taskId:
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
rawAnswer: text,
assisted: usedHelp,
spoken: usedVoice && !transcriptEdited,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
recordingPath = null;
}
final nextStage = stage + 1;
setState(() {
turns.add(DialogueTurn(text: text, isLearner: true));
controller.clear();
stage = nextStage;
waitingForReply = true;
hint = null;
validationError = null;
});
_saveDraft();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
history: turns
.map(
(turn) => <String, String>{
'role': turn.isLearner ? 'user' : 'assistant',
'content': turn.text,
},
)
.toList(),
);
if (!mounted) return;
setState(() {
turns.add(
DialogueTurn(
text:
aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!'),
isLearner: false,
),
);
waitingForReply = false;
});
_saveDraft();
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
);
}
bool _matchesCurrentTask(String response) {
if (!widget.isLessonDialogue) {
final text = response.toLowerCase();
return switch (stage) {
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
2 => RegExp(
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
).hasMatch(text),
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
};
}
final text = response.toLowerCase();
final lessonId = widget.state.activeLessonId;
if (lessonById(lessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
}
if (stage == 0) {
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
}
if (lessonId == 'a0-02' && stage == 1) {
return RegExp(
r'[a-z](?:[ -]?[a-z]){2,}',
caseSensitive: false,
).hasMatch(text);
}
final expected = switch (lessonId) {
'a0-01' => ['nice', 'hello', 'what'],
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
'a0-04' => [
'yes',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'zero',
'what',
],
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
'a0-06' => ['from', 'where', 'bye'],
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
'a0-08' => [
'it',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'clock',
'what',
],
'a0-09' => ['like', 'yes', 'no', 'do'],
'a0-10' => ['please', 'what', 'like'],
_ => ['nice', 'how', 'what', 'from', 'like'],
};
return expected.any(text.contains);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
.segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)]
.id;
void _finish() {
if (widget.isLessonDialogue) {
widget.state.clearDialogueDraft();
widget.state.completeLessonDialogue();
widget.onFinished(null);
return;
}
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
: learnerTurns.first.text;
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
personalSentence: personalSentence,
usedHelp: usedHelp,
),
);
}
Future<void> _playLatestAi({required bool slow}) async {
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
if (latest == null) return;
await VoiceService.instance.speak(latest.text, slow: slow);
if (!slow || !mounted) return;
setState(() => usedHelp = true);
_saveDraft();
}
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
});
if (!mounted) return;
setState(() => listening = available);
if (!available) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
void _showWord() => showLexiconLookup(
context,
state: widget.state,
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
);
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
return AppPage(
appBar: AppBar(
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
),
),
child: SpacedColumn(
children: [
Eyebrow('目标:${script.goal}'),
Container(
constraints: const BoxConstraints(minHeight: 250),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: turns.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final turn = turns[index];
return Align(
alignment: turn.isLearner
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(maxWidth: 290),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: turn.isLearner
? AppColors.warm
: AppColors.softGreen,
borderRadius: BorderRadius.circular(15),
),
child: LexiconText(turn.text, state: widget.state),
),
);
},
),
),
if (!finished) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_AssistChip(
label: '提示',
onTap: () {
setState(() {
usedHelp = true;
hint = script.hints[stage];
});
_saveDraft();
},
),
_AssistChip(
label: '翻译',
onTap: () {
setState(() {
usedHelp = true;
hint = translations[stage];
});
_saveDraft();
},
),
_AssistChip(
label: '慢一点',
onTap: () => _playLatestAi(slow: true),
),
_AssistChip(
label: '重说',
onTap: () => _playLatestAi(slow: false),
),
_AssistChip(label: '查词', onTap: _showWord),
],
),
if (hint != null)
SectionCard(
tint: AppColors.warm,
child: Text(
hint!,
style: const TextStyle(color: AppColors.warmInk),
),
),
if (validationError != null)
Text(
validationError!,
style: const TextStyle(color: AppColors.warmInk),
),
TextField(
controller: controller,
onChanged: (value) => setState(() {
if (usedVoice && value != lastTranscript) {
transcriptEdited = true;
}
}),
onSubmitted: (_) => send(),
decoration: InputDecoration(
hintText: '输入你的英文回答',
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止录音' : '语音输入',
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
),
onPressed: _toggleListening,
),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: waitingForReply ? null : send,
),
border: const OutlineInputBorder(),
),
),
if (usedVoice)
Text(
transcriptEdited
? '你修改了设备转写:这轮按文字作答保存。'
: '这是设备转写;未修改提交后会作为语音尝试保存。',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
Text(
widget.state.keepRecordings
? '录音只保存在本机,不会发送给 AI。'
: '录音仅供本次回听,离开后自动删除。',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const Text(
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
if (waitingForReply) const LinearProgressIndicator(),
] else ...[
SectionCard(
tint: usedHelp ? AppColors.warm : AppColors.softGreen,
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
),
),
PrimaryButton(
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
onPressed: _finish,
),
],
],
),
);
}
}
class _AssistChip extends StatelessWidget {
const _AssistChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => ActionChip(
label: Text(label),
backgroundColor: AppColors.surface,
side: const BorderSide(color: AppColors.line),
onPressed: onTap,
);
}
class DialogueSummaryPage extends StatelessWidget {
const DialogueSummaryPage({
super.key,
required this.summary,
required this.onHome,
required this.onLesson,
required this.onRetry,
});
final DialogueSummaryData summary;
final VoidCallback onHome;
final VoidCallback onLesson;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) => AppPage(
child: SpacedColumn(
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
title: '你的个人复习卡(明天出现)',
sentence: summary.personalSentence,
tint: AppColors.green,
),
),
Text(
summary.usedHelp
? '本次使用过提示。下次可以先不看提示,再试一次。'
: '下次试着换一个名字、地点或喜好,再完成同一任务。',
),
SecondaryButton(label: '再练一次初次见面', onPressed: onRetry),
PrimaryButton(label: '开始一节课程', onPressed: onLesson),
SecondaryButton(label: '回到首页', onPressed: onHome),
],
),
);
}
class _SummaryLine extends StatelessWidget {
const _SummaryLine({
required this.icon,
required this.title,
required this.sentence,
required this.tint,
});
final IconData icon;
final String title;
final String sentence;
final Color tint;
@override
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: tint),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(color: tint, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(sentence),
],
),
),
],
);
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/seed_courses.dart';
import '../../widgets/app_widgets.dart';
class HomePage extends StatelessWidget {
const HomePage({
super.key,
required this.state,
required this.onStartPrimaryTask,
required this.onOpenDialogue,
required this.onResumeLessonDialogue,
});
final AppState state;
final VoidCallback onStartPrimaryTask;
final VoidCallback onOpenDialogue;
final VoidCallback onResumeLessonDialogue;
@override
Widget build(BuildContext context) {
final isReview = state.reviewIsPrimary;
final count = state.dueReviewCount;
final activeLesson = lessonById(state.activeLessonId);
final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1;
final resumeDialogue = state.hasResumableLessonDialogue;
final primaryTitle = resumeDialogue
? '继续第 ${activeLesson.number} 课的课程对话'
: isReview
? '先复习 $count'
: '${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length}' : ''}';
final primaryNote = resumeDialogue
? '已保留你的对话进度和提示状态。'
: isReview
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
: '预热词汇 → 听说读写 → 对话 → 独立尝试';
return AppPage(
child: SpacedColumn(
spacing: 16,
children: [
const Eyebrow('今天的学习'),
Text(
'今天,说几句能用的英语。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
isReview ? '到期复习优先;完成后再进入新内容。' : '没有到期复习,继续完成一个小任务。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
child: Row(
children: [
const Icon(Icons.psychology_outlined, color: AppColors.green),
const SizedBox(width: 10),
Expanded(
child: Text(
'已接触 ${state.knownItemCount} 项 · 可独立使用 ${state.usableMasteryCount}',
),
),
],
),
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
primaryTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Text(
isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟',
style: const TextStyle(color: AppColors.green),
),
],
),
Text(
primaryNote,
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(
label: resumeDialogue
? '继续对话'
: isReview
? '开始复习'
: '开始今天的学习',
onPressed: resumeDialogue
? onResumeLessonDialogue
: onStartPrimaryTask,
),
],
),
),
SectionCard(
child: SpacedColumn(
children: [
const Text(
'AI 情境对话',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'初次见面 · 4 个任务 · 文字或语音输入',
style: Theme.of(context).textTheme.bodyMedium,
),
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
],
),
),
const _FrameworkNote(),
],
),
);
}
}
class _FrameworkNote extends StatelessWidget {
const _FrameworkNote();
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warm,
borderRadius: BorderRadius.circular(14),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: AppColors.warmInk),
SizedBox(width: 8),
Expanded(
child: Text(
'文字回答可用于理解与写作练习;口语掌握须以录音或语音识别结果为准。可在“我的”配置 AI 服务。',
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
height: 1.45,
),
),
),
],
),
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key, required this.state, required this.onContinue});
final AppState state;
final VoidCallback onContinue;
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
late LearningGoal selectedGoal;
late int selectedMinutes;
@override
void initState() {
super.initState();
selectedGoal = widget.state.goal;
selectedMinutes = widget.state.dailyMinutes;
}
@override
Widget build(BuildContext context) {
return AppPage(
child: SpacedColumn(
spacing: 20,
children: [
const Eyebrow('欢迎'),
Text(
'每天 20 分钟,\n说出能用的英语。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
_ChoiceGroup<LearningGoal>(
title: '你的主要目标',
value: selectedGoal,
options: const {
LearningGoal.dailyLife: '日常生活',
LearningGoal.travel: '旅行',
LearningGoal.workStarter: '工作起步',
},
onChanged: (value) => setState(() => selectedGoal = value),
),
_ChoiceGroup<int>(
title: '每天学习多久?',
value: selectedMinutes,
options: const {10: '10 分钟', 20: '20 分钟', 30: '30 分钟'},
onChanged: (value) => setState(() => selectedMinutes = value),
),
PrimaryButton(
label: '继续',
onPressed: () {
widget.state
..setGoal(selectedGoal)
..setDailyMinutes(selectedMinutes);
widget.onContinue();
},
),
],
),
);
}
}
class PlacementPage extends StatefulWidget {
const PlacementPage({super.key, required this.state, required this.onStart});
final AppState state;
final VoidCallback onStart;
@override
State<PlacementPage> createState() => _PlacementPageState();
}
class _PlacementPageState extends State<PlacementPage> {
late PlacementLevel selected;
@override
void initState() {
super.initState();
selected = widget.state.placement;
}
@override
Widget build(BuildContext context) {
const labels = {
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return AppPage(
child: SpacedColumn(
spacing: 14,
children: [
const Eyebrow('第一步 · 约 3 分钟'),
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
const Text('选择最接近的状态,之后随时能调整。'),
for (final option in PlacementLevel.values)
_PlacementChoice(
option: option,
labels: labels[option]!,
isSelected: selected == option,
onTap: () => setState(() => selected = option),
),
PrimaryButton(
label: '开始 3 分钟定位',
onPressed: () {
widget.state.setPlacement(selected);
widget.onStart();
},
),
Center(
child: TextButton(
onPressed: widget.onStart,
child: const Text('直接从第一课开始'),
),
),
],
),
);
}
}
class _PlacementChoice extends StatelessWidget {
const _PlacementChoice({
required this.option,
required this.labels,
required this.isSelected,
required this.onTap,
});
final PlacementLevel option;
final (String, String) labels;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => SectionCard(
tint: isSelected ? AppColors.softGreen : null,
onTap: onTap,
child: Row(
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
color: isSelected ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
labels.$1,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(labels.$2, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class _ChoiceGroup<T> extends StatelessWidget {
const _ChoiceGroup({
required this.title,
required this.value,
required this.options,
required this.onChanged,
});
final String title;
final T value;
final Map<T, String> options;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SpacedColumn(
spacing: 10,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.entries
.map(
(entry) => ChoiceChip(
label: Text(entry.value),
selected: value == entry.key,
selectedColor: AppColors.softGreen,
onSelected: (_) => onChanged(entry.key),
),
)
.toList(),
),
],
),
);
}
}
@@ -0,0 +1,573 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/assessment_bank.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class ProgressPage extends StatelessWidget {
const ProgressPage({
super.key,
required this.state,
required this.onSettings,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onSettings;
final ValueChanged<AssessmentPack> onOpenAssessment;
@override
Widget build(BuildContext context) {
final recall = state.mastery.values
.where((item) => item.status == MasteryStatus.recall)
.length;
final recentEvidence = state.attemptEvidence.reversed.take(5).toList();
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('当前:A0 起步'),
Text('进度来自掌握证据。', style: Theme.of(context).textTheme.headlineMedium),
const Text('不是上完固定课数就升级。核心表达需要在不同时间、不同情境中独立用出。'),
_AbilityRow(
icon: Icons.psychology_outlined,
title: '已接触',
note: '${state.knownItemCount} 个词句或任务',
completed: state.knownItemCount > 0,
),
_AbilityRow(
icon: Icons.replay_outlined,
title: '能回忆',
note: '$recall 项正在巩固',
completed: recall > 0,
),
_AbilityRow(
icon: Icons.record_voice_over_outlined,
title: '可使用',
note: '${state.coreUsableCount} / 48 项核心内容已获得独立使用证据',
completed: state.coreUsableCount >= 48,
),
_AbilityRow(
icon: Icons.verified_outlined,
title: '已掌握(间隔复习)',
note: '${state.coreMasteredCount} / 30 项达到四次间隔复习要求',
completed: state.coreMasteredCount >= 30,
),
_AbilityRow(
icon: Icons.assignment_turned_in_outlined,
title: '两套四技能评估',
note: state.hasTwoValidAssessmentPasses
? '两套不同题组已在有效时间内通过'
: '尚需两套不同题组通过,间隔至少 24 小时',
completed: state.hasTwoValidAssessmentPasses,
),
SectionCard(
tint: state.dueReviewCount > 0
? AppColors.warm
: AppColors.softGreen,
child: Row(
children: [
Icon(
state.dueReviewCount > 0
? Icons.schedule
: Icons.check_circle_outline,
color: state.dueReviewCount > 0
? AppColors.warmInk
: AppColors.green,
),
const SizedBox(width: 10),
Expanded(
child: Text(
state.dueReviewCount > 0
? '${state.dueReviewCount} 项到期复习,完成后会更新掌握证据。'
: '目前没有到期复习。',
),
),
],
),
),
const SectionCard(
child: Text(
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
),
if (state.a0Passed)
const SectionCard(
tint: AppColors.softGreen,
child: Text('A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'),
),
if (recentEvidence.isNotEmpty) ...[
const Text(
'最近学习证据',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('用于解释进度,不是公开记录。'),
for (final evidence in recentEvidence)
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_evidenceLabel(evidence.outcome)} · ${evidence.skill}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (evidence.rawAnswer?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text('你的回答:${evidence.rawAnswer}'),
],
const SizedBox(height: 4),
Text(
_relativeTime(evidence.createdAt),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
const Text(
'A0 四技能评估',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'评估不会提供翻译或句框;第二套题组须在第一套通过至少 24 小时后完成。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final pack in a0AssessmentPacks)
SecondaryButton(
label: state.canStartAssessmentPack(pack.id)
? '开始 ${pack.id}'
: '${pack.id} 需等待第一套评估通过 24 小时',
onPressed: state.canStartAssessmentPack(pack.id)
? () => onOpenAssessment(pack)
: null,
),
SecondaryButton(label: '调整学习与 AI 设置', onPressed: onSettings),
],
),
);
}
static String _evidenceLabel(EvidenceKind outcome) => switch (outcome) {
EvidenceKind.independentSuccess => '独立完成',
EvidenceKind.assisted => '带提示完成',
EvidenceKind.languageError => '需要复核',
EvidenceKind.pending => '稍后完成',
EvidenceKind.exposure => '已查看',
};
static String _relativeTime(DateTime time) {
final difference = DateTime.now().difference(time);
if (difference.inMinutes < 1) return '刚刚';
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
if (difference.inDays < 1) return '${difference.inHours} 小时前';
return '${difference.inDays} 天前';
}
}
class _AbilityRow extends StatelessWidget {
const _AbilityRow({
required this.icon,
required this.title,
required this.note,
required this.completed,
});
final IconData icon;
final String title;
final String note;
final bool completed;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.state});
final AppState state;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
@override
void initState() {
super.initState();
endpoint = TextEditingController(text: widget.state.aiEndpoint);
model = TextEditingController(text: widget.state.aiModel);
}
@override
void dispose() {
endpoint.dispose();
model.dispose();
apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: const Text('学习设置')),
child: SpacedColumn(
spacing: 4,
children: [
_SettingTile(
title: '每日学习时间',
subtitle: '${widget.state.dailyMinutes} 分钟',
onTap: () => _chooseDuration(context),
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('默认显示中文提示'),
subtitle: const Text('A0 阶段开启'),
value: widget.state.showChineseHints,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleChineseHints,
),
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('保存原始录音'),
subtitle: const Text('录音功能启用后仅保存在本机'),
value: widget.state.keepRecordings,
activeThumbColor: AppColors.green,
onChanged: widget.state.toggleKeepRecordings,
),
_SettingTile(
title: '已保存的录音',
subtitle: '回听或删除本机英语练习录音',
onTap: () => _showRecordings(context),
),
_SettingTile(
title: '清除已保存的录音',
subtitle: '只删除本机原始音频,不影响学习进度或 AI 密钥',
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'AI 对话服务',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。密钥不在此页面保存。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
DropdownButtonFormField<AiProviderType>(
initialValue: widget.state.aiProvider,
decoration: const InputDecoration(
labelText: '服务类型',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: AiProviderType.mock,
child: Text('内置练习模式(无需网络)'),
),
DropdownMenuItem(
value: AiProviderType.openAi,
child: Text('OpenAI API'),
),
DropdownMenuItem(
value: AiProviderType.gemini,
child: Text('Gemini API'),
),
DropdownMenuItem(
value: AiProviderType.compatible,
child: Text('OpenAI 兼容 / CLIProxyAPI'),
),
],
onChanged: (value) {
if (value == null) return;
widget.state.setAiProvider(value);
if (value == AiProviderType.gemini &&
endpoint.text.trim().isEmpty) {
endpoint.text =
'https://generativelanguage.googleapis.com/v1beta';
model.text = model.text.trim().isEmpty
? 'gemini-2.5-flash'
: model.text;
}
},
),
TextField(
controller: endpoint,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Base URL(可选)',
hintText:
'OpenAI / 兼容: https://…/v1Gemini https://generativelanguage.googleapis.com/v1beta',
border: OutlineInputBorder(),
),
),
TextField(
controller: model,
decoration: const InputDecoration(
labelText: '模型名称(可选)',
hintText: '例如 gpt-4.1-mini',
border: OutlineInputBorder(),
),
),
TextField(
controller: apiKey,
obscureText: true,
decoration: const InputDecoration(
labelText: 'API Key(仅保存到设备安全存储)',
border: OutlineInputBorder(),
),
),
PrimaryButton(
label: '保存服务设置',
onPressed: () async {
widget.state.saveAiConfiguration(
endpoint: endpoint.text,
model: model.text,
);
if (apiKey.text.trim().isNotEmpty) {
await AiService.instance.saveApiKey(apiKey.text);
}
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
},
),
SecondaryButton(
label: '测试连接',
onPressed: () async {
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(result.message)));
},
),
const Divider(height: 28),
_DangerAction(
label: '清除学习进度',
message:
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。不会删除 API 密钥、AI 服务设置或已保存的原始录音;录音请使用下方独立操作删除。',
onConfirm: widget.state.clearProgress,
),
],
),
);
Future<void> _confirmDeleteRecordings(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清除已保存的录音?'),
content: const Text('这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(context, true),
child: const Text('清除'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final count = await VoiceService.instance.deleteAllRecordings();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
);
}
Future<void> _showRecordings(
BuildContext context,
) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: FutureBuilder<List<String>>(
future: VoiceService.instance.listRecordingPaths(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
final paths = snapshot.data!;
if (paths.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。'),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: SpacedColumn(
children: [
const Text(
'已保存的录音',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
for (final path in paths)
SectionCard(
child: Row(
children: [
const Icon(Icons.mic_none, color: AppColors.green),
const SizedBox(width: 10),
Expanded(
child: Text(
path.split('/').last.replaceAll('.m4a', ''),
),
),
IconButton(
tooltip: '回听',
onPressed: () =>
VoiceService.instance.playRecording(path),
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除',
onPressed: () async {
await VoiceService.instance.deleteRecording(path);
if (context.mounted) Navigator.pop(context);
},
icon: const Icon(Icons.delete_outline),
),
],
),
),
],
),
);
},
),
),
);
Future<void> _chooseDuration(BuildContext context) async {
final value = await showModalBottomSheet<int>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final minute in [10, 20, 30])
ListTile(
title: Text('$minute 分钟'),
trailing: widget.state.dailyMinutes == minute
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, minute),
),
],
),
),
);
if (value != null) widget.state.setDailyMinutes(value);
}
}
class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
required this.onTap,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
class _DangerAction extends StatelessWidget {
const _DangerAction({
required this.label,
required this.message,
required this.onConfirm,
});
final String label;
final String message;
final VoidCallback? onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
trailing: const Icon(Icons.chevron_right, color: Colors.redAccent),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = false;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const SizedBox(height: 12),
const Text(
'会清除:课程步骤、复习、掌握记录、学习证据、评估草稿和课程对话草稿。\n不会清除:已保存录音、AI API Key 与应用设置。',
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: const Text('我了解这些本机学习数据无法恢复'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
onConfirm?.call();
Navigator.pop(context);
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}
@@ -0,0 +1,697 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/ai_service.dart';
import '../../core/generated_content.dart';
import '../../core/models.dart';
import '../../core/review_feedback.dart';
import '../../core/a0_core.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
class ReviewPage extends StatefulWidget {
const ReviewPage({
super.key,
required this.state,
required this.onFinished,
required this.onOpenAdaptiveLesson,
});
final AppState state;
final VoidCallback onFinished;
final VoidCallback onOpenAdaptiveLesson;
@override
State<ReviewPage> createState() => _ReviewPageState();
}
class _ReviewPageState extends State<ReviewPage> {
final controller = TextEditingController();
bool showHint = false;
bool usedHelp = false;
String? validationMessage;
bool generatingVariant = false;
bool generatingLesson = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _next(ReviewItem item, {required bool assisted}) {
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() => validationMessage = result.message);
return;
}
widget.state.completeReview(
item,
assisted: assisted,
rawAnswer: controller.text.trim(),
);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
}
Future<void> _generateVariant(ReviewItem item) async {
setState(() => generatingVariant = true);
final variant = await AiService.instance.generateReviewVariant(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
basePrompt: item.prompt,
);
if (!mounted) return;
setState(() => generatingVariant = false);
if (variant == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未获得合格变式,已继续使用本地审核题。')));
return;
}
widget.state.applyGeneratedReviewVariant(variant);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已生成并审核新题面,核心学习目标不变。')));
}
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
final label = a0CoreItems[item.id];
if (label == null) return;
setState(() => generatingLesson = true);
final lesson = await AiService.instance.generateAdaptiveLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
targetLabel: label,
);
if (lesson == null) {
if (mounted) setState(() => generatingLesson = false);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未生成合格补练,继续使用本地复习题。')));
}
return;
}
final approved = await AiService.instance.auditGeneratedLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
lesson: lesson,
);
if (!mounted) return;
setState(() => generatingLesson = false);
if (!approved) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('补练未通过独立审核,未保存。')));
return;
}
widget.state.cacheApprovedAdaptiveLesson(
lesson,
auditor: '${widget.state.aiProvider.name}:${widget.state.aiModel}',
);
widget.onOpenAdaptiveLesson();
}
@override
Widget build(BuildContext context) {
final item = widget.state.dueReviews.isEmpty
? null
: widget.state.dueReviews.first;
if (item == null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('今日复习已完成'),
Text(
'到期项目已安排下次复练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
],
),
);
}
final checkpoint =
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
final checkpointLabel = checkpoint >= 4
? '30 天抽查'
: '${checkpoint + 1} / 4 个间隔检查点';
return AppPage(
child: SpacedColumn(
children: [
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
Text('不看答案,试着回答。', 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),
),
if (item.isAiGenerated)
Row(
children: [
const Expanded(
child: Text(
'AI 生成题面 · 已通过客户端结构审核',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
),
TextButton(
onPressed: () {
widget.state.reportGeneratedReviewVariant(item);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')),
);
},
child: const Text('内容有问题'),
),
],
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Text(
'情境',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
Text(item.prompt, style: const TextStyle(fontSize: 19)),
],
),
),
TextField(
controller: controller,
minLines: 2,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '输入你会怎么回答',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
label: const Text('需要提示'),
onPressed: () => setState(() {
showHint = true;
usedHelp = true;
validationMessage = null;
}),
),
ActionChip(
label: const Text('稍后复习'),
onPressed: () {
widget.state.postponeReview(item);
setState(() {});
},
),
ActionChip(
label: const Text('暂时想不起来'),
onPressed: () {
widget.state.reportReviewFailure(item);
controller.clear();
setState(() {
showHint = false;
usedHelp = false;
validationMessage = null;
});
},
),
ActionChip(
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
onPressed: generatingVariant
? null
: () => _generateVariant(item),
),
if (a0CoreItems.containsKey(item.id))
ActionChip(
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
onPressed: generatingLesson
? null
: () => _generateAdaptiveLesson(item),
),
],
),
if (showHint)
SectionCard(
tint: AppColors.warm,
child: Text(
'参考:${item.hint}\n目标:${item.target}',
style: const TextStyle(color: AppColors.warmInk),
),
),
if (validationMessage != null)
Text(
validationMessage!,
style: const TextStyle(color: AppColors.warmInk),
),
PrimaryButton(
label: usedHelp ? '带提示完成' : '我能独立回答',
onPressed: controller.text.trim().isEmpty
? null
: () => _next(item, assisted: usedHelp),
),
const Text(
'提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
);
}
}
/// Displays an audited AI-authored reinforcement lesson. It remains a
/// teaching activity: the existing local review and assessment systems own
/// all mastery decisions.
class AdaptiveLessonPage extends StatefulWidget {
const AdaptiveLessonPage({
super.key,
required this.state,
required this.onFinished,
});
final AppState state;
final VoidCallback onFinished;
@override
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
}
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final controller = TextEditingController();
int index = 0;
bool showReference = false;
String? answerFeedback;
bool listening = false;
bool usedVoice = false;
bool transcriptEdited = false;
bool transcriptConfirmed = false;
String lastTranscript = '';
bool recording = false;
bool playingRecording = false;
String? recordingPath;
@override
void initState() {
super.initState();
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null &&
widget.state.adaptiveLessonDraftId == lesson.lessonId) {
index = widget.state.adaptiveLessonDraftIndex.clamp(
0,
lesson.tasks.length,
);
controller.text = widget.state.adaptiveLessonDraftAnswer;
showReference = widget.state.adaptiveLessonDraftReferenceShown;
usedVoice = widget.state.adaptiveLessonDraftUsedVoice;
transcriptEdited = widget.state.adaptiveLessonDraftTranscriptEdited;
transcriptConfirmed = widget.state.adaptiveLessonDraftTranscriptConfirmed;
lastTranscript = widget.state.adaptiveLessonDraftOriginalTranscript;
recordingPath = widget.state.adaptiveLessonDraftRecordingPath;
}
}
@override
void dispose() {
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
controller.dispose();
super.dispose();
}
void _saveDraft(GeneratedLesson lesson) {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: index,
answer: controller.text,
referenceShown: showReference,
usedVoice: usedVoice,
transcriptEdited: transcriptEdited,
transcriptConfirmed: transcriptConfirmed,
originalTranscript: lastTranscript,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
}
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
final correct = matchesAdaptiveLessonAnswer(task, controller.text);
if (!showReference && !correct) {
setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。');
return;
}
widget.state.recordAdaptiveLessonTask(
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted: showReference,
correct: correct,
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
? 'speechToText'
: 'text',
recordingPath: widget.state.keepRecordings ? recordingPath : null,
originalTranscript: usedVoice ? lastTranscript : null,
transcriptConfirmed:
usedVoice && transcriptConfirmed && !transcriptEdited,
transcriptEdited: usedVoice && transcriptEdited,
);
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
final nextIndex = index + 1;
if (nextIndex >= lesson.tasks.length) {
widget.state.clearAdaptiveLessonDraft();
} else {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: nextIndex,
answer: '',
referenceShown: false,
originalTranscript: '',
recordingPath: null,
);
}
setState(() {
controller.clear();
showReference = false;
answerFeedback = null;
usedVoice = false;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = '';
recordingPath = null;
index = nextIndex;
});
}
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
});
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
});
if (!mounted) return;
setState(() => listening = ready);
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
@override
Widget build(BuildContext context) {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson == null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('AI 四技能补练'),
const Text('没有可用的已审核补练。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final done = index >= lesson.tasks.length;
if (done) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('补练已完成'),
Text(
'你完成了这组四技能教学补练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('这组 AI 内容只作教学复练;掌握度仍由本地复习与出口评估的有效证据决定。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final task = lesson.tasks[index];
final showStimulus = task.skill != 'listening';
return AppPage(
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
child: SpacedColumn(
children: [
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
if (widget.state.cachedAdaptiveLessonAuditedAt != null)
Text(
'审核:${widget.state.cachedAdaptiveLessonAuditor ?? '已配置服务'} · '
'${_formatAuditTime(widget.state.cachedAdaptiveLessonAuditedAt!)}',
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
Text(task.prompt, style: Theme.of(context).textTheme.headlineMedium),
if (showStimulus)
SectionCard(
tint: AppColors.softGreen,
child: Text(task.stimulus, style: const TextStyle(fontSize: 20)),
)
else
const SectionCard(
tint: AppColors.surfaceMuted,
child: Text('先播放音频,再输入你听到的答案。'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(task.stimulus),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(task.stimulus, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
],
),
TextField(
controller: controller,
minLines: 2,
onChanged: (_) {
if (usedVoice && controller.text != lastTranscript) {
transcriptEdited = true;
transcriptConfirmed = false;
}
_saveDraft(lesson);
setState(() {});
},
decoration: InputDecoration(
hintText: '输入或说出你的答案后,再继续',
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止语音输入' : '语音输入',
onPressed: _toggleListening,
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
),
),
border: OutlineInputBorder(),
),
),
if (task.skill == 'speaking')
SectionCard(
tint: AppColors.surfaceMuted,
child: SpacedColumn(
spacing: 8,
children: [
Text(
widget.state.keepRecordings
? '可录音回听并仅保存在本机;不会发送给 AI。'
: '可录音回听;离开本页后会自动删除。',
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: listening ? null : _toggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (recordingPath != null) ...[
const SizedBox(width: 8),
IconButton(
tooltip: playingRecording ? '正在播放' : '回听录音',
onPressed: playingRecording ? null : _playRecording,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: _deleteRecording,
icon: const Icon(Icons.delete_outline),
),
],
],
),
],
),
),
if (usedVoice)
SectionCard(
tint: transcriptEdited || !transcriptConfirmed
? AppColors.warm
: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Text(
transcriptEdited
? '你修改了设备转写:本次将按文字教学练习保存。'
: transcriptConfirmed
? '已确认设备转写:会保留本次语音输入记录。'
: '请核对设备转写;确认前会按文字教学练习保存。',
),
if (!transcriptEdited && !transcriptConfirmed)
TextButton(
onPressed: () {
setState(() => transcriptConfirmed = true);
_saveDraft(lesson);
},
child: const Text('确认转写无误'),
),
],
),
),
if (answerFeedback != null)
Text(
answerFeedback!,
style: const TextStyle(color: AppColors.warmInk),
),
if (showReference)
SectionCard(
tint: AppColors.warm,
child: Text('参考表达:${task.answer}'),
)
else
TextButton(
onPressed: () {
setState(() => showReference = true);
_saveDraft(lesson);
},
child: const Text('需要帮助,查看参考表达'),
),
TextButton.icon(
onPressed: () {
widget.state.reportAdaptiveLesson(lesson);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离这组 AI 补练内容,并返回复习。')),
);
widget.onFinished();
},
icon: const Icon(Icons.flag_outlined),
label: const Text('内容有问题'),
),
PrimaryButton(
label: index == lesson.tasks.length - 1 ? '完成补练' : '下一项',
onPressed: controller.text.trim().isNotEmpty
? () => _advance(lesson, task)
: null,
),
],
),
);
}
String _skillLabel(String skill) => switch (skill) {
'listening' => '听力',
'speaking' => '口语',
'reading' => '阅读',
_ => '写作',
};
String _formatAuditTime(DateTime value) =>
'${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')} '
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}
@@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/assessment_bank.dart';
import '../../widgets/app_widgets.dart';
import '../dialogue/dialogue_flow.dart';
import '../assessment/assessment_page.dart';
import '../home/home_page.dart';
import '../lesson/lesson_flow.dart';
import '../progress/progress_pages.dart';
import '../review/review_page.dart';
class LearningShell extends StatefulWidget {
const LearningShell({super.key, required this.state});
final AppState state;
@override
State<LearningShell> createState() => _LearningShellState();
}
class _LearningShellState extends State<LearningShell> {
AppTab tab = AppTab.home;
var route = _ShellRoute.tab;
bool dialogueInLesson = false;
AssessmentPack? assessmentPack;
DialogueSummaryData? dialogueSummary;
void showTab(AppTab value) => setState(() {
tab = value;
route = _ShellRoute.tab;
});
void showLesson() => setState(() => route = _ShellRoute.lesson);
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
void showDialogue({bool inLesson = false}) => setState(() {
dialogueInLesson = inLesson;
route = _ShellRoute.dialogue;
});
void showSummary(DialogueSummaryData summary) => setState(() {
dialogueSummary = summary;
route = _ShellRoute.summary;
});
void showSettings() => setState(() => route = _ShellRoute.settings);
void showAssessment(AssessmentPack pack) => setState(() {
assessmentPack = pack;
route = _ShellRoute.assessmentPreparation;
});
void startAssessment() => setState(() => route = _ShellRoute.assessment);
void showAdaptiveLesson() =>
setState(() => route = _ShellRoute.adaptiveLesson);
@override
Widget build(BuildContext context) {
Widget body;
switch (route) {
case _ShellRoute.lesson:
body = LessonFlow(
state: widget.state,
onOpenDialogue: () => showDialogue(inLesson: true),
onFinish: () => showTab(AppTab.home),
);
case _ShellRoute.scene:
body = DialogueScenePage(onStart: showDialogue);
case _ShellRoute.dialogue:
body = DialoguePage(
state: widget.state,
isLessonDialogue: dialogueInLesson,
onFinished: dialogueInLesson
? (_) => showLesson()
: (summary) {
if (summary != null) showSummary(summary);
},
);
case _ShellRoute.summary:
body = DialogueSummaryPage(
summary: dialogueSummary!,
onHome: () => showTab(AppTab.home),
onLesson: showLesson,
onRetry: showDialogue,
);
case _ShellRoute.settings:
body = SettingsPage(state: widget.state);
case _ShellRoute.adaptiveLesson:
body = AdaptiveLessonPage(
state: widget.state,
onFinished: () => showTab(AppTab.review),
);
case _ShellRoute.assessment:
body = AssessmentPage(
state: widget.state,
pack: assessmentPack!,
onFinished: () => showTab(AppTab.progress),
onStartReplacement: showAssessment,
);
case _ShellRoute.assessmentPreparation:
body = AssessmentPreparationPage(
state: widget.state,
pack: assessmentPack!,
onStart: startAssessment,
onBack: () => showTab(AppTab.progress),
);
case _ShellRoute.tab:
body = _tabContent();
}
return AnimatedBuilder(
animation: widget.state,
builder: (context, _) => Scaffold(
body: body,
bottomNavigationBar: route == _ShellRoute.tab
? NavigationBar(
selectedIndex: tab.index,
height: 70,
indicatorColor: AppColors.softGreen,
onDestinationSelected: (index) => showTab(AppTab.values[index]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: '学习',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: '对话',
),
NavigationDestination(
icon: Icon(Icons.refresh_outlined),
selectedIcon: Icon(Icons.refresh),
label: '复习',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
)
: null,
),
);
}
Widget _tabContent() {
switch (tab) {
case AppTab.home:
return HomePage(
state: widget.state,
onStartPrimaryTask: widget.state.reviewIsPrimary
? () => showTab(AppTab.review)
: showLesson,
onOpenDialogue: showDialogue,
onResumeLessonDialogue: () => showDialogue(inLesson: true),
);
case AppTab.learn:
return _LearningMap(
onOpenLesson: (id) {
widget.state.openLesson(id);
showLesson();
},
onStartReinforcement: () {
widget.state.scheduleA0Reinforcement();
showTab(AppTab.review);
},
onOpenReview: () => showTab(AppTab.review),
state: widget.state,
);
case AppTab.dialogue:
return DialogueScenePage(onStart: showDialogue);
case AppTab.review:
return ReviewPage(
state: widget.state,
onFinished: () => showTab(AppTab.home),
onOpenAdaptiveLesson: showAdaptiveLesson,
);
case AppTab.progress:
return ProgressPage(
state: widget.state,
onSettings: showSettings,
onOpenAssessment: showAssessment,
);
}
}
}
enum _ShellRoute {
tab,
lesson,
scene,
dialogue,
summary,
settings,
adaptiveLesson,
assessmentPreparation,
assessment,
}
class _LearningMap extends StatelessWidget {
const _LearningMap({
required this.onOpenLesson,
required this.onStartReinforcement,
required this.onOpenReview,
required this.state,
});
final ValueChanged<String> onOpenLesson;
final VoidCallback onStartReinforcement;
final VoidCallback onOpenReview;
final AppState state;
@override
Widget build(BuildContext context) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('学习地图 · 按掌握状态推进'),
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
const Text('每节课都围绕一个能完成的小任务。'),
if (state.reviewBacklog)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text(
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
style: const TextStyle(color: AppColors.warmInk),
),
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
],
),
),
for (final lesson in a0SeedLessons)
SectionCard(
tint: lesson.id == state.activeLessonId
? AppColors.softGreen
: null,
onTap:
state.isLessonUnlocked(lesson.id) &&
(!state.reviewBacklog ||
state.completedLessonIds.contains(lesson.id))
? () => onOpenLesson(lesson.id)
: null,
child: Row(
children: [
Icon(
state.completedLessonIds.contains(lesson.id)
? Icons.check_circle
: state.reviewBacklog
? Icons.lock_outline
: state.isLessonUnlocked(lesson.id)
? Icons.play_circle_outline
: Icons.lock_outline,
color: state.completedLessonIds.contains(lesson.id)
? AppColors.green
: AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${lesson.number} 课 · ${lesson.title}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(
lesson.segments.length > 1
? '${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}'
: lesson.outcome,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
),
if (state.completedLessonIds.length == a0SeedLessons.length)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Text(
'A0 巩固变式',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
PrimaryButton(
label: '安排一题巩固练习',
onPressed: onStartReinforcement,
),
],
),
),
],
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'core/app_state.dart';
import 'core/app_theme.dart';
import 'features/onboarding/onboarding_pages.dart';
import 'features/shell/learning_shell.dart';
void main() {
runApp(const KouyuEnglishApp());
}
class KouyuEnglishApp extends StatefulWidget {
const KouyuEnglishApp({super.key});
@override
State<KouyuEnglishApp> createState() => _KouyuEnglishAppState();
}
class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
final appState = AppState();
var onboardingStep = 0;
@override
void initState() {
super.initState();
appState.load();
}
@override
void dispose() {
appState.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '开口英语',
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 PlacementPage(
state: appState,
onStart: () => appState.finishOnboarding(),
);
},
),
);
}
}
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import '../core/app_theme.dart';
class AppPage extends StatelessWidget {
const AppPage({
super.key,
required this.child,
this.appBar,
this.bottomNavigationBar,
});
final Widget child;
final PreferredSizeWidget? appBar;
final Widget? bottomNavigationBar;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
body: SafeArea(
top: appBar == null,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: child,
),
),
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({
super.key,
required this.child,
this.tint,
this.onTap,
this.padding = const EdgeInsets.all(14),
});
final Widget child;
final Color? tint;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Material(
color: tint ?? AppColors.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(16),
),
child: content,
),
),
);
}
}
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? const SizedBox.shrink() : Icon(icon),
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: AppColors.green,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
}
class SecondaryButton extends StatelessWidget {
const SecondaryButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.ink,
side: const BorderSide(color: AppColors.line),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Text(label),
),
);
}
}
class Eyebrow extends StatelessWidget {
const Eyebrow(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 13,
),
);
}
class SpacedColumn extends StatelessWidget {
const SpacedColumn({super.key, required this.children, this.spacing = 12});
final List<Widget> children;
final double spacing;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index < children.length - 1) SizedBox(height: spacing),
],
],
);
}
@@ -0,0 +1,342 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../core/app_state.dart';
import '../core/app_theme.dart';
import '../core/ai_service.dart';
import '../core/models.dart';
import '../core/seed_courses.dart';
import '../core/voice_service.dart';
import 'app_widgets.dart';
List<VocabularyItem> get courseLexiconEntries {
final entries = <VocabularyItem>[
...a0SeedLessons.expand((lesson) => lesson.vocabulary),
...a0SegmentVocabulary.values.expand((items) => items),
];
final seen = <String>{};
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
..sort((a, b) => b.word.length.compareTo(a.word.length));
}
/// Finds a course item by exact query, then by the longest known phrase in it.
VocabularyItem? findCourseLexicon(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
return pattern.hasMatch(normalized);
}, orElse: () => null);
}
/// Inline course text that lets a learner tap a known word or phrase without
/// leaving the current task. Longest phrases are matched before their words.
class LexiconText extends StatefulWidget {
const LexiconText(
this.text, {
super.key,
required this.state,
this.style,
this.textAlign,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
@override
State<LexiconText> createState() => _LexiconTextState();
}
class _LexiconTextState extends State<LexiconText> {
final List<TapGestureRecognizer> _recognizers = [];
String selectedText = '';
@override
void dispose() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
final entries = courseLexiconEntries;
if (entries.isEmpty) return Text(widget.text, style: widget.style);
final expression = RegExp(
r'(?<![a-zA-Z0-9])(?:' +
entries
.map(
(item) => RegExp.escape(item.word).replaceAll('', "[']"),
)
.join('|') +
r')(?![a-zA-Z0-9])',
caseSensitive: false,
);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in expression.allMatches(widget.text)) {
if (match.start > cursor) {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
}
final matched = widget.text.substring(match.start, match.end);
final entry = findCourseLexicon(matched);
if (entry == null) {
spans.add(TextSpan(text: matched));
} else {
final recognizer = TapGestureRecognizer()
..onTap = () => showLexiconLookup(
context,
state: widget.state,
initialText: entry.word,
);
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: matched,
recognizer: recognizer,
style: const TextStyle(
color: AppColors.green,
decoration: TextDecoration.underline,
decorationColor: AppColors.green,
),
),
);
}
cursor = match.end;
}
if (cursor < widget.text.length) {
spans.add(TextSpan(text: widget.text.substring(cursor)));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
onSelectionChanged: (selection, _) {
final text = selection.isValid && !selection.isCollapsed
? widget.text.substring(selection.start, selection.end).trim()
: '';
if (text != selectedText && mounted) {
setState(() => selectedText = text);
}
},
),
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
),
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
],
);
}
}
Future<void> showLexiconLookup(
BuildContext context, {
required AppState state,
String initialText = '',
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
_LexiconLookupSheet(state: state, initialText: initialText),
);
class _LexiconLookupSheet extends StatefulWidget {
const _LexiconLookupSheet({required this.state, required this.initialText});
final AppState state;
final String initialText;
@override
State<_LexiconLookupSheet> createState() => _LexiconLookupSheetState();
}
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
late final TextEditingController controller;
VocabularyItem? entry;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
: null;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
?.definition;
temporaryError = null;
});
Future<void> _requestTemporaryDefinition() async {
final text = controller.text.trim();
if (text.isEmpty || widget.state.aiProvider.name == 'mock') {
setState(() => temporaryError = '未配置 AI 服务时,只能查询本地已审核课程词典。');
return;
}
setState(() {
requestingTemporaryDefinition = true;
temporaryError = null;
});
final definition = await AiService.instance.temporaryDefinition(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingTemporaryDefinition = false;
temporaryDefinition = definition;
temporaryError = definition == null ? '暂时无法查询;结果不会被编造。' : null;
});
if (definition != null) {
widget.state.cacheTemporaryDefinition(
query: text,
definition: definition,
);
}
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SpacedColumn(
children: [
const Eyebrow('课程词典 · 短语优先'),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入或粘贴英文词、短语、句子',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _lookup,
),
),
),
if (entry == null) ...[
const SectionCard(
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
),
OutlinedButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text('待审核临时释义\n$temporaryDefinition'),
Text(
'仅保存在本机,不会加入复习或影响掌握度。',
style: Theme.of(context).textTheme.bodySmall,
),
TextButton.icon(
onPressed: () {
widget.state.removeTemporaryDefinition(controller.text);
setState(() => temporaryDefinition = null);
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('删除此临时释义'),
),
],
),
),
] else ...[
Text(
entry!.word,
style: Theme.of(context).textTheme.headlineMedium,
),
if (entry!.ipa != null)
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
SectionCard(
tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(entry!.word),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(entry!.word, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
],
),
PrimaryButton(
label: '加入复习',
onPressed: () {
widget.state.addSavedWord(entry!);
Navigator.pop(context);
},
),
],
],
),
),
);
}