Compare commits
9
Commits
c2361c5c3a
...
d72959df70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72959df70 | ||
|
|
4d324903b4 | ||
|
|
29b1738162 | ||
|
|
f79ba6327e | ||
|
|
9b0f9d64cb | ||
|
|
6c0aeec628 | ||
|
|
70f7be9b88 | ||
|
|
a69d221ab6 | ||
|
|
2795987c3a |
@@ -3,6 +3,6 @@
|
||||
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
|
||||
"model": "gemini-3.7-flash-high",
|
||||
"reasoningEffort": "low",
|
||||
"apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf",
|
||||
"apiKey": "sk-gZKpQQ5ybcL6WFersPKMiDDEZFxjC8xCASHzc08SNFTwa3LwRr8SaNNuvPzal5Tg",
|
||||
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
|
||||
}
|
||||
|
||||
@@ -145,10 +145,12 @@ class AiService {
|
||||
try {
|
||||
if (provider == AiProviderType.gemini) {
|
||||
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {'x-goog-api-key': key, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
final response = await _postJson(
|
||||
provider: provider,
|
||||
key: key,
|
||||
uri: uri,
|
||||
timeout: const Duration(seconds: 25),
|
||||
body: {
|
||||
'contents': [
|
||||
{
|
||||
'parts': [
|
||||
@@ -169,18 +171,17 @@ class AiService {
|
||||
'thinkingBudget': 1024,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).timeout(const Duration(seconds: 25));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
},
|
||||
);
|
||||
if (!_isSuccess(response)) return null;
|
||||
return _extractResponseContent(provider, response.body);
|
||||
} else {
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $key',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
final response = await _postJson(
|
||||
provider: provider,
|
||||
key: key,
|
||||
uri: uri,
|
||||
timeout: const Duration(seconds: 25),
|
||||
body: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
{
|
||||
@@ -202,9 +203,9 @@ class AiService {
|
||||
],
|
||||
'reasoning_effort': 'low',
|
||||
'temperature': 0.1,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 25));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
},
|
||||
);
|
||||
if (!_isSuccess(response)) return null;
|
||||
final raw = _extractResponseContent(provider, response.body);
|
||||
if (raw == null) return null;
|
||||
var text = raw.trim();
|
||||
@@ -218,6 +219,151 @@ class AiService {
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isHttpUri(Uri uri) => uri.scheme == 'https' || uri.scheme == 'http';
|
||||
|
||||
static bool _isSuccess(http.Response response) =>
|
||||
response.statusCode >= 200 && response.statusCode < 300;
|
||||
|
||||
static Future<http.Response> _postJson({
|
||||
required AiProviderType provider,
|
||||
required String key,
|
||||
required Uri uri,
|
||||
required Map<String, dynamic> body,
|
||||
required Duration timeout,
|
||||
}) {
|
||||
return 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(body),
|
||||
)
|
||||
.timeout(timeout);
|
||||
}
|
||||
|
||||
/// Sends a JSON-mode text request and returns the model's content.
|
||||
///
|
||||
/// Returns null when the API key, model or endpoint is unusable, when the
|
||||
/// request fails, or when the status is not 2xx. With [system] the messages
|
||||
/// are a role-tagged conversation; without it they are a single prompt.
|
||||
Future<String?> _requestContent({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
String? system,
|
||||
required List<Map<String, String>> messages,
|
||||
double? temperature,
|
||||
required int maxTokens,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
final key = await resolveApiKey();
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
model.trim().isEmpty ||
|
||||
uri == null ||
|
||||
!_isHttpUri(uri)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final response = await _postJson(
|
||||
provider: provider,
|
||||
key: key,
|
||||
uri: uri,
|
||||
timeout: timeout,
|
||||
body: _buildTextPayload(
|
||||
provider: provider,
|
||||
uri: uri,
|
||||
model: model,
|
||||
system: system,
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
maxTokens: maxTokens,
|
||||
),
|
||||
);
|
||||
if (!_isSuccess(response)) return null;
|
||||
return _extractResponseContent(provider, response.body);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _requestPrompt({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String prompt,
|
||||
double? temperature,
|
||||
required int maxTokens,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) {
|
||||
return _requestContent(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
temperature: temperature,
|
||||
maxTokens: maxTokens,
|
||||
timeout: timeout,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildTextPayload({
|
||||
required AiProviderType provider,
|
||||
required Uri uri,
|
||||
required String model,
|
||||
String? system,
|
||||
required List<Map<String, String>> messages,
|
||||
double? temperature,
|
||||
required int maxTokens,
|
||||
}) {
|
||||
if (provider != AiProviderType.gemini) {
|
||||
return _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
if (system != null) {'role': 'system', 'content': system},
|
||||
...messages,
|
||||
],
|
||||
temperature: temperature,
|
||||
maxTokens: maxTokens,
|
||||
);
|
||||
}
|
||||
return {
|
||||
if (system != null)
|
||||
'systemInstruction': {
|
||||
'parts': [
|
||||
{'text': system},
|
||||
],
|
||||
},
|
||||
'contents': [
|
||||
for (final message in messages)
|
||||
{
|
||||
if (system != null)
|
||||
'role': message['role'] == 'assistant' ? 'model' : 'user',
|
||||
'parts': [
|
||||
{'text': message['content']},
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: temperature,
|
||||
maxOutputTokens: maxTokens,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildOpenAiPayload({
|
||||
required Uri uri,
|
||||
required String model,
|
||||
@@ -275,65 +421,17 @@ class AiService {
|
||||
text.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final key = await resolveApiKey();
|
||||
final uri = resolveEndpointUri(
|
||||
const instruction =
|
||||
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
model.trim().isEmpty ||
|
||||
uri == null ||
|
||||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
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': _buildGeminiGenerationConfig(
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': '$instruction\nText: $text',
|
||||
},
|
||||
],
|
||||
prompt: '$instruction\nText: $text',
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
);
|
||||
if (content == null || content.length > 300) return null;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
if (parsed is! Map<String, dynamic>) return null;
|
||||
final definition = parsed['definition'];
|
||||
@@ -362,20 +460,6 @@ class AiService {
|
||||
return _buildMockSentenceAnalysis(cleanText);
|
||||
}
|
||||
|
||||
final key = await resolveApiKey();
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
model.trim().isEmpty ||
|
||||
uri == null ||
|
||||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instruction =
|
||||
'You are an expert oral English coach for beginner adult learners (A0-A1). '
|
||||
'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. '
|
||||
@@ -396,50 +480,13 @@ class AiService {
|
||||
' ]\n'
|
||||
'}';
|
||||
|
||||
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\n\nSentence: $cleanText'},
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
maxOutputTokens: 800,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': '$instruction\n\nSentence: $cleanText',
|
||||
},
|
||||
],
|
||||
prompt: '$instruction\n\nSentence: $cleanText',
|
||||
maxTokens: 800,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
);
|
||||
if (content == null || content.isEmpty) return null;
|
||||
return _decodeSentenceAnalysis(
|
||||
raw: content,
|
||||
@@ -447,9 +494,6 @@ class AiService {
|
||||
provider: provider.name,
|
||||
model: model,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
SentenceAnalysisResult? _decodeSentenceAnalysis({
|
||||
@@ -614,39 +658,20 @@ class AiService {
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
if (uri == null || !_isHttpUri(uri)) {
|
||||
return const AiConnectionResult(
|
||||
ok: false,
|
||||
message: '请使用有效的 HTTP 或 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': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
final response = await _postJson(
|
||||
provider: provider,
|
||||
key: key,
|
||||
uri: uri,
|
||||
timeout: const Duration(seconds: 15),
|
||||
body: _buildTextPayload(
|
||||
provider: provider,
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
@@ -655,10 +680,8 @@ class AiService {
|
||||
temperature: 0,
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
);
|
||||
if (_isSuccess(response)) {
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (_decodeDialogueResponse(content) != null) {
|
||||
return const AiConnectionResult(
|
||||
@@ -716,21 +739,6 @@ class AiService {
|
||||
if (provider == AiProviderType.mock) {
|
||||
return null;
|
||||
}
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
return null;
|
||||
}
|
||||
final vocabularyRule = allowedLanguage.isEmpty
|
||||
? ''
|
||||
: 'Build your reply from your goal wording, names, numbers and this '
|
||||
@@ -748,64 +756,16 @@ class AiService {
|
||||
'"translation": "reply 的简体中文翻译", '
|
||||
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
|
||||
'Only reply is required.';
|
||||
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},
|
||||
],
|
||||
},
|
||||
'contents': history
|
||||
.map(
|
||||
(turn) => {
|
||||
'role': turn['role'] == 'assistant'
|
||||
? 'model'
|
||||
: 'user',
|
||||
'parts': [
|
||||
{'text': turn['content']},
|
||||
],
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
final content = await _requestContent(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'system', 'content': system},
|
||||
...history,
|
||||
],
|
||||
system: system,
|
||||
messages: history,
|
||||
temperature: 0.3,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
return _decodeDialogueResponse(
|
||||
_extractResponseContent(provider, response.body),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return _decodeDialogueResponse(content);
|
||||
}
|
||||
|
||||
/// Generates only a bounded variant of an existing review target. A network
|
||||
@@ -819,63 +779,24 @@ class AiService {
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = resolveEndpointUri(
|
||||
final instruction =
|
||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". '
|
||||
'Return JSON only with exactly these five fields and nothing else: '
|
||||
'schemaVersion (must be "review-variant-1"), '
|
||||
'variantId (short id such as "ai-${targetItemId.toLowerCase()}-1", maximum 80 characters), '
|
||||
'targetItemId (must be "$targetItemId"), '
|
||||
'prompt (a new short Chinese situation asking the learner to say the same target expression, maximum 120 Chinese characters), '
|
||||
'expectedAnswer (the English reference answer, maximum 12 words; use [place], [name] or [number] for learner-specific details). '
|
||||
'Stay strictly within A0. Do not introduce new vocabulary or change the target expression.'
|
||||
'${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||||
final instruction =
|
||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||||
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': _buildGeminiGenerationConfig(
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
prompt: instruction,
|
||||
temperature: 0.2,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
);
|
||||
if (content == null) return null;
|
||||
final variant = decodeGeneratedReviewVariant(
|
||||
content,
|
||||
@@ -892,9 +813,6 @@ class AiService {
|
||||
);
|
||||
}
|
||||
return variant;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates an open-ended writing response against a bounded schema.
|
||||
@@ -907,19 +825,6 @@ class AiService {
|
||||
required String answer,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) 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".
|
||||
@@ -927,56 +832,19 @@ verdict must be accepted, rewrite, or uncertain. feedback is one short helpful C
|
||||
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.
|
||||
Task: $taskPrompt
|
||||
Learner wrote: $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': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
prompt: instruction,
|
||||
temperature: 0,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
);
|
||||
if (content == null) return null;
|
||||
return decodeWritingAiFeedback(
|
||||
content,
|
||||
expectedLessonId: lessonId,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
||||
@@ -989,64 +857,18 @@ Learner wrote: $answer''';
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) 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': _buildGeminiGenerationConfig(
|
||||
temperature: 0.1,
|
||||
maxOutputTokens: 850,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
prompt: instruction,
|
||||
temperature: 0.1,
|
||||
maxTokens: 850,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 45));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
timeout: const Duration(seconds: 45),
|
||||
);
|
||||
if (content == null) return null;
|
||||
final lesson = decodeGeneratedLesson(
|
||||
content,
|
||||
@@ -1063,9 +885,6 @@ Learner wrote: $answer''';
|
||||
);
|
||||
}
|
||||
return lesson;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the entire generated lesson structure to an independent LLM audit.
|
||||
@@ -1076,19 +895,6 @@ Learner wrote: $answer''';
|
||||
required GeneratedLesson lesson,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return true;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
|
||||
final lessonJson = jsonEncode({
|
||||
'lessonId': lesson.lessonId,
|
||||
'stageVersion': lesson.stageVersion,
|
||||
@@ -1113,53 +919,15 @@ Learner wrote: $answer''';
|
||||
});
|
||||
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': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
prompt: instruction,
|
||||
temperature: 0,
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return false;
|
||||
}
|
||||
return _decodeLessonAudit(
|
||||
_extractResponseContent(provider, response.body),
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return _decodeLessonAudit(content);
|
||||
}
|
||||
|
||||
bool _decodeLessonAudit(String? raw) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Locally cached AI output: temporary glosses, sentence analyses and the
|
||||
/// audited adaptive lesson with its resumable draft.
|
||||
mixin _AiContent on _AppStateData, _ReviewAndMastery {
|
||||
String _normalizeLexiconQuery(String value) =>
|
||||
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
TemporaryLexiconEntry? temporaryDefinitionFor(String query) =>
|
||||
temporaryLexicon[_normalizeLexiconQuery(query)];
|
||||
|
||||
void cacheTemporaryDefinition({
|
||||
required String query,
|
||||
required String definition,
|
||||
}) {
|
||||
final key = _normalizeLexiconQuery(query);
|
||||
final normalizedDefinition = definition.trim();
|
||||
if (key.isEmpty || normalizedDefinition.isEmpty) return;
|
||||
temporaryLexicon[key] = TemporaryLexiconEntry(
|
||||
query: query.trim(),
|
||||
definition: normalizedDefinition,
|
||||
provider: aiProvider.name,
|
||||
model: aiModel.trim(),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeTemporaryDefinition(String query) {
|
||||
if (temporaryLexicon.remove(_normalizeLexiconQuery(query)) != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
SentenceAnalysisResult? sentenceAnalysisFor(String query) =>
|
||||
sentenceAnalyses[_normalizeLexiconQuery(query)];
|
||||
|
||||
void cacheSentenceAnalysis(SentenceAnalysisResult result) {
|
||||
final key = _normalizeLexiconQuery(result.originalText);
|
||||
if (key.isEmpty || result.translation.trim().isEmpty) return;
|
||||
sentenceAnalyses[key] = result;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeSentenceAnalysis(String query) {
|
||||
if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// AI adaptive lessons are bounded teaching material. Only a locally
|
||||
/// validated, unassisted answer can add limited teaching evidence; spaced
|
||||
/// review checkpoints and stage assessment remain the source of `master`.
|
||||
void recordAdaptiveLessonTask({
|
||||
required GeneratedLesson lesson,
|
||||
required GeneratedLessonTask task,
|
||||
required String rawAnswer,
|
||||
required bool assisted,
|
||||
required bool correct,
|
||||
String inputMode = 'text',
|
||||
String? recordingPath,
|
||||
String? originalTranscript,
|
||||
bool transcriptConfirmed = false,
|
||||
bool transcriptEdited = false,
|
||||
}) {
|
||||
final stableTaskId = '${lesson.lessonId}-${task.taskId}';
|
||||
// A bounded adaptive lesson presents each task once. Protect against a
|
||||
// double tap or a retried UI callback creating two independent successes
|
||||
// for the same material revision.
|
||||
if (attemptEvidence.any((entry) => entry.taskId == stableTaskId)) return;
|
||||
final now = DateTime.now();
|
||||
// Treat the voice label as an evidence boundary, not caller-provided
|
||||
// metadata. An edited transcript is a useful written learning response,
|
||||
// but can never become speech evidence merely because a UI caller forgot
|
||||
// to clear its confirmation flag.
|
||||
final confirmedVoiceTranscript =
|
||||
inputMode == 'speechToText' && transcriptConfirmed && !transcriptEdited;
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'adaptive-${lesson.lessonId}-${task.taskId}-${now.microsecondsSinceEpoch}',
|
||||
itemId: task.targetItemIds.single,
|
||||
taskId: stableTaskId,
|
||||
skill: switch (task.skill) {
|
||||
'listening' => '听力理解',
|
||||
'speaking' => '口语表达',
|
||||
'reading' => '阅读理解',
|
||||
_ => '写作表达',
|
||||
},
|
||||
inputMode: confirmedVoiceTranscript ? 'speechToText' : 'text',
|
||||
outcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: correct
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.pending,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
originalTranscript: originalTranscript,
|
||||
transcriptConfirmed: confirmedVoiceTranscript,
|
||||
transcriptEdited: transcriptEdited,
|
||||
),
|
||||
);
|
||||
if (!assisted && correct) {
|
||||
_recordEvidence(
|
||||
task.targetItemIds.single,
|
||||
EvidenceKind.independentSuccess,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
GeneratedLesson? get cachedAdaptiveLesson {
|
||||
final raw = cachedAdaptiveLessonRaw;
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final targets = data['targetItemIds'] as List<dynamic>?;
|
||||
final target = targets?.singleOrNull;
|
||||
final lesson = target is String
|
||||
? decodeGeneratedLesson(raw, expectedTargetItemId: target)
|
||||
: null;
|
||||
return lesson != null &&
|
||||
!reportedAdaptiveLessonIds.contains(lesson.lessonId)
|
||||
? lesson
|
||||
: null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Call only after an independent AI audit approves the lesson. The audit
|
||||
/// metadata stays beside the immutable lesson revision for later tracing.
|
||||
void cacheApprovedAdaptiveLesson(
|
||||
GeneratedLesson lesson, {
|
||||
DateTime? auditedAt,
|
||||
String? auditor,
|
||||
}) {
|
||||
cachedAdaptiveLessonRaw = encodeGeneratedLesson(lesson);
|
||||
cachedAdaptiveLessonAuditedAt = auditedAt ?? DateTime.now();
|
||||
cachedAdaptiveLessonAuditor = auditor;
|
||||
adaptiveLessonDraftId = lesson.lessonId;
|
||||
adaptiveLessonDraftIndex = 0;
|
||||
adaptiveLessonDraftAnswer = '';
|
||||
adaptiveLessonDraftReferenceShown = false;
|
||||
adaptiveLessonDraftUsedVoice = false;
|
||||
adaptiveLessonDraftTranscriptEdited = false;
|
||||
adaptiveLessonDraftTranscriptConfirmed = false;
|
||||
adaptiveLessonDraftOriginalTranscript = '';
|
||||
adaptiveLessonDraftRecordingPath = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stores progress after every input change so an interrupted AI teaching
|
||||
/// activity can resume at exactly the same task. This is not mastery data.
|
||||
void saveAdaptiveLessonDraft({
|
||||
required GeneratedLesson lesson,
|
||||
required int taskIndex,
|
||||
required String answer,
|
||||
required bool referenceShown,
|
||||
bool usedVoice = false,
|
||||
bool transcriptEdited = false,
|
||||
bool transcriptConfirmed = false,
|
||||
String originalTranscript = '',
|
||||
String? recordingPath,
|
||||
}) {
|
||||
final confirmedVoiceTranscript =
|
||||
usedVoice && transcriptConfirmed && !transcriptEdited;
|
||||
adaptiveLessonDraftId = lesson.lessonId;
|
||||
adaptiveLessonDraftIndex = taskIndex;
|
||||
adaptiveLessonDraftAnswer = answer;
|
||||
adaptiveLessonDraftReferenceShown = referenceShown;
|
||||
adaptiveLessonDraftUsedVoice = usedVoice;
|
||||
adaptiveLessonDraftTranscriptEdited = transcriptEdited;
|
||||
adaptiveLessonDraftTranscriptConfirmed = confirmedVoiceTranscript;
|
||||
adaptiveLessonDraftOriginalTranscript = originalTranscript;
|
||||
adaptiveLessonDraftRecordingPath = recordingPath;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearAdaptiveLessonDraft() {
|
||||
adaptiveLessonDraftId = null;
|
||||
adaptiveLessonDraftIndex = 0;
|
||||
adaptiveLessonDraftAnswer = '';
|
||||
adaptiveLessonDraftReferenceShown = false;
|
||||
adaptiveLessonDraftUsedVoice = false;
|
||||
adaptiveLessonDraftTranscriptEdited = false;
|
||||
adaptiveLessonDraftTranscriptConfirmed = false;
|
||||
adaptiveLessonDraftOriginalTranscript = '';
|
||||
adaptiveLessonDraftRecordingPath = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A reported AI lesson must never be shown again from this local cache.
|
||||
/// Existing submissions stay as pending/assisted learning history; they
|
||||
/// never contributed to mastery and therefore need no mastery rollback.
|
||||
void reportAdaptiveLesson(GeneratedLesson lesson) {
|
||||
reportedAdaptiveLessonIds.add(lesson.lessonId);
|
||||
if (cachedAdaptiveLesson?.lessonId == lesson.lessonId) {
|
||||
cachedAdaptiveLessonRaw = null;
|
||||
cachedAdaptiveLessonAuditedAt = null;
|
||||
cachedAdaptiveLessonAuditor = null;
|
||||
}
|
||||
adaptiveLessonDraftId = null;
|
||||
adaptiveLessonDraftIndex = 0;
|
||||
adaptiveLessonDraftAnswer = '';
|
||||
adaptiveLessonDraftReferenceShown = false;
|
||||
adaptiveLessonDraftUsedVoice = false;
|
||||
adaptiveLessonDraftTranscriptEdited = false;
|
||||
adaptiveLessonDraftTranscriptConfirmed = false;
|
||||
adaptiveLessonDraftOriginalTranscript = '';
|
||||
adaptiveLessonDraftRecordingPath = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Stage assessments and the A0 pass criteria.
|
||||
mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
|
||||
bool get hasTwoValidAssessmentPasses {
|
||||
final passed = assessments.where((record) => record.passed).toList()
|
||||
..sort((a, b) => b.completedAt.compareTo(a.completedAt));
|
||||
if (passed.length < 2) {
|
||||
return false;
|
||||
}
|
||||
final latest = passed.first;
|
||||
if (DateTime.now().difference(latest.completedAt) >
|
||||
const Duration(days: 30)) {
|
||||
return false;
|
||||
}
|
||||
return passed
|
||||
.skip(1)
|
||||
.any(
|
||||
(record) =>
|
||||
record.packId != latest.packId &&
|
||||
latest.completedAt.difference(record.completedAt) >=
|
||||
const Duration(hours: 24),
|
||||
);
|
||||
}
|
||||
|
||||
bool get a0Passed =>
|
||||
coreUsableCount >= 48 &&
|
||||
coreMasteredCount >= 30 &&
|
||||
hasTwoValidAssessmentPasses;
|
||||
|
||||
bool canStartAssessmentPack(String packId) {
|
||||
if (packId != 'A0-E2') return true;
|
||||
final first = assessments
|
||||
.where((record) => record.packId == 'A0-E1' && record.passed)
|
||||
.firstOrNull;
|
||||
return first != null &&
|
||||
DateTime.now().difference(first.completedAt) >=
|
||||
const Duration(hours: 24);
|
||||
}
|
||||
|
||||
AssessmentRecord recordAssessment(AssessmentRecord record) {
|
||||
final canonicalPackId = record.packId.endsWith('R')
|
||||
? record.packId.substring(0, record.packId.length - 1)
|
||||
: record.packId;
|
||||
final normalized = AssessmentRecord(
|
||||
packId: canonicalPackId,
|
||||
completedAt: record.completedAt,
|
||||
results: record.results,
|
||||
pendingSkills: record.pendingSkills,
|
||||
);
|
||||
final previous = assessments
|
||||
.where((item) => item.packId == canonicalPackId)
|
||||
.firstOrNull;
|
||||
final isWithinWindow =
|
||||
previous != null &&
|
||||
record.completedAt.difference(previous.completedAt) <=
|
||||
const Duration(days: 7);
|
||||
final merged = AssessmentRecord(
|
||||
packId: canonicalPackId,
|
||||
completedAt: normalized.completedAt,
|
||||
results: {
|
||||
for (final skill in AssessmentSkill.values)
|
||||
skill:
|
||||
normalized.results[skill] == true ||
|
||||
(isWithinWindow && previous.results[skill] == true),
|
||||
},
|
||||
pendingSkills: {
|
||||
for (final skill in AssessmentSkill.values)
|
||||
if (normalized.results[skill] != true &&
|
||||
(normalized.pendingSkills.contains(skill) ||
|
||||
(!normalized.results.containsKey(skill) &&
|
||||
isWithinWindow &&
|
||||
previous.pendingSkills.contains(skill))))
|
||||
skill,
|
||||
},
|
||||
);
|
||||
assessments.removeWhere((item) => item.packId == canonicalPackId);
|
||||
assessments.add(merged);
|
||||
notifyListeners();
|
||||
return merged;
|
||||
}
|
||||
|
||||
void saveAssessmentDraft(AssessmentDraft draft) {
|
||||
assessmentDraft = draft;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearAssessmentDraft() {
|
||||
if (assessmentDraft == null) return;
|
||||
assessmentDraft = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordAssessmentAttempt({
|
||||
required String taskId,
|
||||
required String skill,
|
||||
required bool correct,
|
||||
required String rawAnswer,
|
||||
required bool spoken,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'assessment-$taskId-${now.microsecondsSinceEpoch}',
|
||||
itemId: taskId,
|
||||
taskId: taskId,
|
||||
skill: skill,
|
||||
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
|
||||
outcome: correct
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.languageError,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordAssessmentPending({
|
||||
required String taskId,
|
||||
required String skill,
|
||||
required String reason,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'assessment-pending-$taskId-${now.microsecondsSinceEpoch}',
|
||||
itemId: taskId,
|
||||
taskId: taskId,
|
||||
skill: skill,
|
||||
inputMode: 'unavailable',
|
||||
outcome: EvidenceKind.pending,
|
||||
createdAt: now,
|
||||
rawAnswer: reason,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Position in the seed course, the in-lesson step flow, and the evidence
|
||||
/// lesson tasks produce.
|
||||
mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
bool get hasResumableLessonDialogue =>
|
||||
dialogueDraft != null &&
|
||||
dialogueDraft!.lessonId == activeLessonId &&
|
||||
!lessonDialogueComplete;
|
||||
|
||||
int activeSegmentIndexFor(String lessonId) =>
|
||||
activeSegmentIndexes[lessonId] ?? 0;
|
||||
|
||||
String get _activeSegmentId {
|
||||
final lesson = lessonById(activeLessonId);
|
||||
return lesson.segments[activeSegmentIndexFor(activeLessonId)].id;
|
||||
}
|
||||
|
||||
bool isSegmentComplete(String segmentId) =>
|
||||
completedSegmentIds.contains(segmentId);
|
||||
|
||||
void completeSegment(String lessonId, int segmentIndex) {
|
||||
final segments = lessonById(lessonId).segments;
|
||||
if (segmentIndex < 0 || segmentIndex >= segments.length) return;
|
||||
completedSegmentIds.add(segments[segmentIndex].id);
|
||||
activeSegmentIndexes[lessonId] = (segmentIndex + 1).clamp(
|
||||
0,
|
||||
segments.length - 1,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void saveDialogueDraft(DialogueDraft draft) {
|
||||
dialogueDraft = draft;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearDialogueDraft() {
|
||||
if (dialogueDraft == null) return;
|
||||
dialogueDraft = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void saveSceneDialogueDraft(DialogueDraft draft) {
|
||||
sceneDialogueDraft = draft;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSceneDialogueDraft() {
|
||||
if (sceneDialogueDraft == null) return;
|
||||
sceneDialogueDraft = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get lessonCanComplete =>
|
||||
lessonListeningComplete &&
|
||||
lessonSpeakingComplete &&
|
||||
lessonReadingComplete &&
|
||||
lessonWritingComplete &&
|
||||
lessonDialogueComplete &&
|
||||
independentAttemptComplete;
|
||||
|
||||
void advanceLesson(LessonStep value) {
|
||||
lessonStep = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPreviewIndex(int value) {
|
||||
previewIndex = value < 0 ? 0 : value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLessonWritingDraft(String value) {
|
||||
if (lessonWritingDraft == value) return;
|
||||
lessonWritingDraft = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setIndependentAttemptDraft(String value) {
|
||||
if (independentAttemptDraft == value) return;
|
||||
independentAttemptDraft = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isLessonUnlocked(String id) {
|
||||
final index = a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||
return index == 0 ||
|
||||
(index > 0 && completedLessonIds.contains(a0SeedLessons[index - 1].id));
|
||||
}
|
||||
|
||||
void openLesson(String id) {
|
||||
if (!isLessonUnlocked(id)) return;
|
||||
activeLessonId = id;
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonWritingDraft = '';
|
||||
independentAttemptDraft = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completePreview() {
|
||||
previewIndex = 0;
|
||||
lessonStep = LessonStep.listening;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeListening() {
|
||||
_introduceLessonTargets();
|
||||
lessonListeningComplete = true;
|
||||
lessonStep = LessonStep.speaking;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeSpeaking({bool assisted = false}) {
|
||||
lessonSpeakingComplete = true;
|
||||
lessonStep = LessonStep.reading;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: [_primaryTargetId],
|
||||
taskSuffix: 'speaking',
|
||||
skill: '口语表达',
|
||||
outcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
assisted: assisted,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeReading() {
|
||||
lessonReadingComplete = true;
|
||||
lessonStep = LessonStep.writing;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: [_primaryTargetId],
|
||||
taskSuffix: 'reading',
|
||||
skill: '阅读理解',
|
||||
outcome: EvidenceKind.exposure,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeWriting({bool assisted = false, String? rawAnswer}) {
|
||||
lessonWritingComplete = true;
|
||||
lessonWritingDraft = '';
|
||||
lessonStep = LessonStep.dialogue;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: [_primaryTargetId],
|
||||
taskSuffix: 'writing',
|
||||
skill: '写作表达',
|
||||
outcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
assisted: assisted,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeLessonDialogue() {
|
||||
lessonDialogueComplete = true;
|
||||
lessonStep = LessonStep.independent;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: [_primaryTargetId],
|
||||
taskSuffix: 'dialogue',
|
||||
skill: '受控对话',
|
||||
outcome: EvidenceKind.assisted,
|
||||
assisted: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeIndependentAttempt({
|
||||
required bool assisted,
|
||||
bool spoken = false,
|
||||
String? rawAnswer,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
independentAttemptComplete = true;
|
||||
independentAttemptDraft = '';
|
||||
independentAttemptAssisted = assisted;
|
||||
independentAttemptSpoken = spoken && !assisted;
|
||||
lessonStep = LessonStep.complete;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: [_primaryTargetId],
|
||||
taskSuffix: 'independent',
|
||||
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
||||
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
||||
outcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void finishLesson() {
|
||||
if (!lessonCanComplete) return;
|
||||
completedLessonIds.add(activeLessonId);
|
||||
completedLessons = completedLessonIds.length;
|
||||
final currentIndex = a0SeedLessons.indexWhere(
|
||||
(lesson) => lesson.id == activeLessonId,
|
||||
);
|
||||
if (currentIndex >= 0 && currentIndex < a0SeedLessons.length - 1) {
|
||||
activeLessonId = a0SeedLessons[currentIndex + 1].id;
|
||||
}
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
_syncInBackground();
|
||||
}
|
||||
|
||||
/// Merges lesson progress pulled from the sync server.
|
||||
///
|
||||
/// Completed lessons/segments are unioned, and the learner's position only
|
||||
/// ever moves forward: a device that has not caught up yet must never drag
|
||||
/// another device back to an earlier lesson or segment.
|
||||
bool mergeSyncedLessonProgress({
|
||||
required Iterable<String> completedLessons,
|
||||
required Iterable<String> completedSegments,
|
||||
required String remoteActiveLessonId,
|
||||
}) {
|
||||
var changed = false;
|
||||
final completedBefore = completedLessonIds.toSet();
|
||||
for (final id in completedLessons) {
|
||||
if (completedLessonIds.add(id)) changed = true;
|
||||
}
|
||||
for (final id in completedSegments) {
|
||||
if (completedSegmentIds.add(id)) changed = true;
|
||||
}
|
||||
if (this.completedLessons != completedLessonIds.length) {
|
||||
this.completedLessons = completedLessonIds.length;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
int lessonIndex(String id) =>
|
||||
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||
|
||||
var targetIndex = lessonIndex(activeLessonId);
|
||||
final remoteIndex = lessonIndex(remoteActiveLessonId);
|
||||
if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) {
|
||||
targetIndex = remoteIndex;
|
||||
}
|
||||
// The server's active lesson can itself be stale. Skip lessons that only
|
||||
// became complete through this pull, but leave a lesson alone when the
|
||||
// learner deliberately reopened it locally after finishing it.
|
||||
while (targetIndex >= 0 &&
|
||||
targetIndex < a0SeedLessons.length - 1 &&
|
||||
!completedBefore.contains(a0SeedLessons[targetIndex].id) &&
|
||||
completedLessonIds.contains(a0SeedLessons[targetIndex].id)) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
var positionChanged = false;
|
||||
if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) {
|
||||
activeLessonId = a0SeedLessons[targetIndex].id;
|
||||
positionChanged = true;
|
||||
}
|
||||
|
||||
for (final lesson in a0SeedLessons) {
|
||||
final segments = lesson.segments;
|
||||
var firstOpen = segments.indexWhere(
|
||||
(segment) => !completedSegmentIds.contains(segment.id),
|
||||
);
|
||||
if (firstOpen < 0) firstOpen = segments.length - 1;
|
||||
if (firstOpen > activeSegmentIndexFor(lesson.id)) {
|
||||
activeSegmentIndexes[lesson.id] = firstOpen;
|
||||
if (lesson.id == activeLessonId) positionChanged = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (positionChanged) {
|
||||
_resetLessonFlow();
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
void _resetLessonFlow() {
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonListeningComplete = false;
|
||||
lessonSpeakingComplete = false;
|
||||
lessonReadingComplete = false;
|
||||
lessonWritingComplete = false;
|
||||
lessonDialogueComplete = false;
|
||||
independentAttemptComplete = false;
|
||||
independentAttemptAssisted = false;
|
||||
independentAttemptSpoken = false;
|
||||
lessonWritingDraft = '';
|
||||
independentAttemptDraft = '';
|
||||
}
|
||||
|
||||
void finishCurrentLessonSegment() {
|
||||
final lesson = lessonById(activeLessonId);
|
||||
final index = activeSegmentIndexFor(activeLessonId);
|
||||
if (index >= lesson.segments.length - 1) {
|
||||
completeSegment(activeLessonId, index);
|
||||
finishLesson();
|
||||
return;
|
||||
}
|
||||
completeSegment(activeLessonId, index);
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
_syncInBackground();
|
||||
}
|
||||
|
||||
void recordDialogueAttempt({
|
||||
required String taskId,
|
||||
required String rawAnswer,
|
||||
required bool assisted,
|
||||
bool spoken = false,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'dialogue-$taskId-${now.microsecondsSinceEpoch}',
|
||||
itemId: _primaryTargetId,
|
||||
taskId: taskId,
|
||||
skill: '受控对话',
|
||||
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
|
||||
outcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<String> get _activeTargetItemIds {
|
||||
final lesson = lessonById(activeLessonId);
|
||||
return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds;
|
||||
}
|
||||
|
||||
String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02';
|
||||
|
||||
/// Records one evidence row for every target actually attached to a local
|
||||
/// task. A task can be displayed once but must never silently award its
|
||||
/// result to unrelated core items.
|
||||
void _recordLessonTaskEvidence({
|
||||
required List<String> targetIds,
|
||||
required String taskSuffix,
|
||||
required String skill,
|
||||
required EvidenceKind outcome,
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? recordingPath,
|
||||
bool assisted = false,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (var index = 0; index < targetIds.length; index++) {
|
||||
final id = targetIds[index];
|
||||
_recordEvidence(id, outcome);
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index',
|
||||
itemId: id,
|
||||
taskId: 'lesson-$_activeSegmentId-$taskSuffix',
|
||||
skill: skill,
|
||||
inputMode: inputMode,
|
||||
outcome: outcome,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _introduceLessonTargets() {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
taskSuffix: 'listening',
|
||||
skill: '听力输入',
|
||||
outcome: EvidenceKind.exposure,
|
||||
);
|
||||
for (final id in _activeTargetItemIds) {
|
||||
final introduced = mastery[id]!;
|
||||
if (introduced.firstTaughtAt == null) {
|
||||
mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now());
|
||||
}
|
||||
if (reviewQueue.any((item) => item.id == id)) continue;
|
||||
final template = coreReviewTemplate(id);
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: a0CoreItems[id] ?? id,
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
skill: template.skill,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Review queue scheduling and the mastery derived from attempt evidence.
|
||||
mixin _ReviewAndMastery on _AppStateData {
|
||||
List<ReviewItem> get dueReviews {
|
||||
final now = DateTime.now();
|
||||
final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList()
|
||||
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
|
||||
return due;
|
||||
}
|
||||
|
||||
int get dueReviewCount => dueReviews.length;
|
||||
bool get reviewIsPrimary => dueReviewCount > 0;
|
||||
int get reviewBudgetSeconds => switch (dailyMinutes) {
|
||||
10 => 3 * 60,
|
||||
30 => 8 * 60,
|
||||
_ => 5 * 60,
|
||||
};
|
||||
int get dueReviewEstimatedSeconds => dueReviewCount * 60;
|
||||
bool get reviewBacklog {
|
||||
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7));
|
||||
return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 ||
|
||||
dueReviews.any((item) => item.dueAt.isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
int get knownItemCount => mastery.length;
|
||||
|
||||
int get coreUsableCount => mastery.entries
|
||||
.where(
|
||||
(entry) =>
|
||||
a0CoreItems.containsKey(entry.key) &&
|
||||
(entry.value.status == MasteryStatus.use ||
|
||||
entry.value.status == MasteryStatus.master) &&
|
||||
!entry.value.needsReview,
|
||||
)
|
||||
.length;
|
||||
|
||||
int get coreMasteredCount => mastery.entries
|
||||
.where(
|
||||
(entry) =>
|
||||
a0CoreItems.containsKey(entry.key) &&
|
||||
entry.value.status == MasteryStatus.master &&
|
||||
!entry.value.needsReview,
|
||||
)
|
||||
.length;
|
||||
|
||||
int get usableMasteryCount => mastery.values
|
||||
.where(
|
||||
(item) =>
|
||||
item.status == MasteryStatus.use ||
|
||||
item.status == MasteryStatus.master,
|
||||
)
|
||||
.length;
|
||||
|
||||
void addPhraseToReview({
|
||||
required String phrase,
|
||||
required String meaning,
|
||||
String? ipa,
|
||||
String? usageNote,
|
||||
required String contextSentence,
|
||||
}) {
|
||||
final cleanPhrase = phrase.trim();
|
||||
if (cleanPhrase.isEmpty) return;
|
||||
final id =
|
||||
'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}';
|
||||
final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty
|
||||
? '$meaning ($usageNote)'
|
||||
: meaning;
|
||||
final item = VocabularyItem(
|
||||
id: id,
|
||||
word: cleanPhrase,
|
||||
meaning: fullMeaning,
|
||||
example: contextSentence.trim(),
|
||||
exampleMeaning: meaning,
|
||||
ipa: ipa,
|
||||
);
|
||||
addSavedWord(item);
|
||||
}
|
||||
|
||||
void completeReview(
|
||||
ReviewItem item, {
|
||||
required bool assisted,
|
||||
String? rawAnswer,
|
||||
}) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
final now = DateTime.now();
|
||||
// UI/network retries may still hold an old item instance. Only the
|
||||
// currently due queue entry is allowed to produce evidence.
|
||||
if (current.dueAt.isAfter(now)) return;
|
||||
// A review is never silently discarded. Success earns a wider interval;
|
||||
// an assisted answer gets another, different attempt tomorrow.
|
||||
final progressedToday =
|
||||
current.lastProgressedAt != null &&
|
||||
current.lastProgressedAt!.year == now.year &&
|
||||
current.lastProgressedAt!.month == now.month &&
|
||||
current.lastProgressedAt!.day == now.day;
|
||||
final canProgress = !assisted && !progressedToday;
|
||||
final nextSuccesses = canProgress
|
||||
? current.successfulReviews + 1
|
||||
: current.successfulReviews;
|
||||
const intervals = [1, 2, 4];
|
||||
final intervalIndex = (nextSuccesses - 1)
|
||||
.clamp(0, intervals.length - 1)
|
||||
.toInt();
|
||||
final masteryItem = mastery[current.id];
|
||||
final days = assisted || !canProgress
|
||||
? 1
|
||||
: masteryItem?.needsReview == true &&
|
||||
masteryItem?.status == MasteryStatus.master
|
||||
? 7
|
||||
: nextSuccesses >= 4
|
||||
? 30
|
||||
: intervals[intervalIndex];
|
||||
reviewQueue[index] = current.copyWith(
|
||||
dueAt: DateTime.now().add(Duration(days: days)),
|
||||
attempts: current.attempts + 1,
|
||||
successfulReviews: nextSuccesses,
|
||||
lastProgressedAt: canProgress ? now : current.lastProgressedAt,
|
||||
);
|
||||
if (assisted) {
|
||||
_recordEvidence(current.id, EvidenceKind.assisted);
|
||||
} else {
|
||||
_recordReviewSuccess(current.id, nextSuccesses);
|
||||
}
|
||||
_addAttemptEvidence(
|
||||
current,
|
||||
outcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
assisted: assisted,
|
||||
rawAnswer: rawAnswer,
|
||||
);
|
||||
notifyListeners();
|
||||
_syncInBackground();
|
||||
}
|
||||
|
||||
void reportReviewFailure(ReviewItem item) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final existing =
|
||||
mastery[item.id] ??
|
||||
MasteryItem(
|
||||
id: item.id,
|
||||
label: item.target,
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
);
|
||||
final secondFailure = existing.needsReview;
|
||||
final nextCheckpoint = secondFailure
|
||||
? (existing.checkpoint - 1).clamp(0, 4).toInt()
|
||||
: existing.checkpoint;
|
||||
mastery[item.id] = existing.copyWith(
|
||||
status: secondFailure
|
||||
? _statusForCheckpoint(nextCheckpoint)
|
||||
: existing.status,
|
||||
checkpoint: nextCheckpoint,
|
||||
needsReview: true,
|
||||
evidence: [...existing.evidence, EvidenceKind.languageError],
|
||||
);
|
||||
reviewQueue[index] = item.copyWith(
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
attempts: item.attempts + 1,
|
||||
successfulReviews: secondFailure
|
||||
? nextCheckpoint
|
||||
: item.successfulReviews,
|
||||
);
|
||||
_addAttemptEvidence(item, outcome: EvidenceKind.languageError);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void postponeReview(ReviewItem item) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
if (index < 0) return;
|
||||
reviewQueue[index] = item.copyWith(
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
attempts: item.attempts + 1,
|
||||
);
|
||||
_addAttemptEvidence(item, outcome: EvidenceKind.pending);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _addAttemptEvidence(
|
||||
ReviewItem item, {
|
||||
required EvidenceKind outcome,
|
||||
bool assisted = false,
|
||||
String? rawAnswer,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'review-${item.id}-${now.microsecondsSinceEpoch}',
|
||||
itemId: item.id,
|
||||
taskId: 'review-${item.id}',
|
||||
skill: item.skill,
|
||||
inputMode: 'text',
|
||||
outcome: outcome,
|
||||
createdAt: now,
|
||||
rawAnswer: rawAnswer,
|
||||
assisted: assisted,
|
||||
variantIndex: item.variantIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addSavedWord(VocabularyItem item) {
|
||||
if (reviewQueue.any((review) => review.id == item.id)) return;
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: item.id,
|
||||
target: item.word,
|
||||
prompt: item.example,
|
||||
hint: item.meaning,
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
skill: '认识与回忆',
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Adds one low-priority, non-core recap based on a completed independent
|
||||
/// dialogue. It is deliberately separate from A0 denominator items.
|
||||
void addDialogueRecap(String sentence) {
|
||||
final now = DateTime.now();
|
||||
final day =
|
||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
final id = 'dialogue-a0-meet-$day';
|
||||
if (reviewQueue.any((item) => item.id == id)) return;
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: sentence,
|
||||
prompt: '再用英语介绍一次自己。',
|
||||
hint: '试着不用提示,说出你刚才表达的内容。',
|
||||
dueAt: now.add(const Duration(days: 1)),
|
||||
skill: '情境复练',
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void scheduleA0Reinforcement() {
|
||||
final candidates = a0CoreItems.keys.toList()
|
||||
..sort((left, right) {
|
||||
final leftItem = mastery[left];
|
||||
final rightItem = mastery[right];
|
||||
final leftRank = leftItem?.needsReview == true
|
||||
? -1
|
||||
: leftItem?.checkpoint ?? 0;
|
||||
final rightRank = rightItem?.needsReview == true
|
||||
? -1
|
||||
: rightItem?.checkpoint ?? 0;
|
||||
return leftRank == rightRank
|
||||
? left.compareTo(right)
|
||||
: leftRank.compareTo(rightRank);
|
||||
});
|
||||
if (candidates.isEmpty) return;
|
||||
final id = candidates.first;
|
||||
final index = reviewQueue.indexWhere((item) => item.id == id);
|
||||
if (index >= 0) {
|
||||
final current = reviewQueue[index];
|
||||
final template = coreReviewVariant(id, current.variantIndex + 1);
|
||||
reviewQueue[index] = current.copyWith(
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
skill: template.skill,
|
||||
dueAt: DateTime.now(),
|
||||
variantIndex: current.variantIndex + 1,
|
||||
);
|
||||
} else {
|
||||
final template = coreReviewVariant(id, 1);
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: a0CoreItems[id]!,
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: DateTime.now(),
|
||||
skill: template.skill,
|
||||
variantIndex: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(item) => item.id == variant.targetItemId,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
reviewQueue[index] = current.copyWith(
|
||||
prompt: variant.prompt,
|
||||
hint: variant.expectedAnswer,
|
||||
variantIndex: current.variantIndex + 1,
|
||||
isAiGenerated: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Removes an AI-authored review wording from use. If it had already been
|
||||
/// answered, only evidence tied to that exact variant is invalidated and
|
||||
/// its checkpoint contribution is removed; the stable core item remains.
|
||||
void reportGeneratedReviewVariant(ReviewItem item) {
|
||||
if (!item.isAiGenerated) return;
|
||||
final index = reviewQueue.indexWhere(
|
||||
(candidate) => candidate.id == item.id,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final key = '${item.id}:${item.variantIndex}';
|
||||
if (!reportedAiVariantKeys.add(key)) return;
|
||||
final invalidSuccesses = attemptEvidence
|
||||
.where(
|
||||
(evidence) =>
|
||||
evidence.itemId == item.id &&
|
||||
evidence.taskId == 'review-${item.id}' &&
|
||||
evidence.variantIndex == item.variantIndex &&
|
||||
evidence.outcome == EvidenceKind.independentSuccess,
|
||||
)
|
||||
.length;
|
||||
attemptEvidence.removeWhere(
|
||||
(evidence) =>
|
||||
evidence.itemId == item.id &&
|
||||
evidence.taskId == 'review-${item.id}' &&
|
||||
evidence.variantIndex == item.variantIndex,
|
||||
);
|
||||
if (invalidSuccesses > 0) _rebuildMasteryItem(item.id, forceReview: true);
|
||||
final template = coreReviewVariant(item.id, item.variantIndex + 1);
|
||||
reviewQueue[index] = item.copyWith(
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
skill: template.skill,
|
||||
variantIndex: item.variantIndex + 1,
|
||||
dueAt: DateTime.now(),
|
||||
isAiGenerated: false,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Rebuilds displayed mastery from immutable local attempt evidence. AI
|
||||
/// responses never supply this state. It is safe to call after isolating a
|
||||
/// bad generated variant because removed evidence can no longer contribute.
|
||||
void rebuildMasteryFromEvidence() {
|
||||
final ids = {
|
||||
...mastery.keys,
|
||||
...attemptEvidence.map((entry) => entry.itemId),
|
||||
};
|
||||
for (final id in ids) {
|
||||
_rebuildMasteryItem(id);
|
||||
}
|
||||
for (var index = 0; index < reviewQueue.length; index++) {
|
||||
final item = reviewQueue[index];
|
||||
final rebuilt = mastery[item.id];
|
||||
if (rebuilt != null) {
|
||||
reviewQueue[index] = item.copyWith(
|
||||
successfulReviews: rebuilt.checkpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _rebuildMasteryItem(String id, {bool forceReview = false}) {
|
||||
final existing = mastery[id];
|
||||
final events = attemptEvidence.where((entry) => entry.itemId == id).toList()
|
||||
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
|
||||
var checkpoint = 0;
|
||||
var needsReview = forceReview;
|
||||
var nonReviewSuccesses = 0;
|
||||
DateTime? firstTaughtAt;
|
||||
final progressedDays = <String>{};
|
||||
for (final event in events) {
|
||||
final isReview = event.taskId == 'review-$id';
|
||||
if (!isReview &&
|
||||
event.outcome == EvidenceKind.exposure &&
|
||||
(firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) {
|
||||
firstTaughtAt = event.createdAt;
|
||||
}
|
||||
if (!isReview && event.outcome == EvidenceKind.independentSuccess) {
|
||||
nonReviewSuccesses++;
|
||||
continue;
|
||||
}
|
||||
if (!isReview) continue;
|
||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||
final day =
|
||||
'${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}';
|
||||
if (progressedDays.add(day)) checkpoint = (checkpoint + 1).clamp(0, 4);
|
||||
needsReview = false;
|
||||
} else if (event.outcome == EvidenceKind.languageError) {
|
||||
if (needsReview) checkpoint = (checkpoint - 1).clamp(0, 4);
|
||||
needsReview = true;
|
||||
}
|
||||
}
|
||||
final independentLevel = nonReviewSuccesses.clamp(0, 3);
|
||||
final level = checkpoint > independentLevel ? checkpoint : independentLevel;
|
||||
mastery[id] = MasteryItem(
|
||||
id: id,
|
||||
label: existing?.label ?? a0CoreItems[id] ?? id,
|
||||
status: _statusForCheckpoint(level),
|
||||
checkpoint: checkpoint,
|
||||
needsReview: needsReview,
|
||||
evidence: events.map((event) => event.outcome).toList(),
|
||||
firstTaughtAt: firstTaughtAt ?? existing?.firstTaughtAt,
|
||||
);
|
||||
}
|
||||
|
||||
void _recordEvidence(String id, EvidenceKind evidence) {
|
||||
final existing =
|
||||
mastery[id] ??
|
||||
MasteryItem(
|
||||
id: id,
|
||||
label: id,
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
);
|
||||
final allEvidence = [...existing.evidence, evidence];
|
||||
MasteryStatus next = existing.status;
|
||||
if (evidence == EvidenceKind.independentSuccess) {
|
||||
next = switch (existing.status) {
|
||||
MasteryStatus.newItem => MasteryStatus.recognize,
|
||||
MasteryStatus.recognize => MasteryStatus.recall,
|
||||
MasteryStatus.recall => MasteryStatus.use,
|
||||
MasteryStatus.use || MasteryStatus.master => existing.status,
|
||||
MasteryStatus.needsReview => MasteryStatus.recall,
|
||||
};
|
||||
}
|
||||
mastery[id] = existing.copyWith(status: next, evidence: allEvidence);
|
||||
}
|
||||
|
||||
void _recordReviewSuccess(String id, int successes) {
|
||||
final existing =
|
||||
mastery[id] ??
|
||||
MasteryItem(
|
||||
id: id,
|
||||
label: a0CoreItems[id] ?? id,
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
);
|
||||
final checkpoint = successes.clamp(0, 4).toInt();
|
||||
mastery[id] = existing.copyWith(
|
||||
status: _statusForCheckpoint(checkpoint),
|
||||
checkpoint: checkpoint,
|
||||
needsReview: false,
|
||||
evidence: [...existing.evidence, EvidenceKind.independentSuccess],
|
||||
);
|
||||
}
|
||||
|
||||
MasteryStatus _statusForCheckpoint(int checkpoint) => switch (checkpoint) {
|
||||
0 => MasteryStatus.newItem,
|
||||
1 => MasteryStatus.recognize,
|
||||
2 => MasteryStatus.recall,
|
||||
3 => MasteryStatus.use,
|
||||
_ => MasteryStatus.master,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// Local snapshot format of [AppState]. Keys are persisted on device, so
|
||||
/// renaming one silently drops that part of a learner's saved progress.
|
||||
extension _AppStateSnapshot on AppState {
|
||||
void _restore(Map<String, dynamic> data) {
|
||||
onboardingComplete =
|
||||
data['onboardingComplete'] as bool? ?? onboardingComplete;
|
||||
goal = _enumValue(LearningGoal.values, data['goal'] as String?, goal);
|
||||
placement = _enumValue(
|
||||
PlacementLevel.values,
|
||||
data['placement'] as String?,
|
||||
placement,
|
||||
);
|
||||
dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes;
|
||||
showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints;
|
||||
keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings;
|
||||
aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint;
|
||||
aiModel = data['aiModel'] as String? ?? aiModel;
|
||||
cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?;
|
||||
cachedAdaptiveLessonAuditedAt = DateTime.tryParse(
|
||||
data['cachedAdaptiveLessonAuditedAt'] as String? ?? '',
|
||||
);
|
||||
cachedAdaptiveLessonAuditor =
|
||||
data['cachedAdaptiveLessonAuditor'] as String?;
|
||||
adaptiveLessonDraftId = data['adaptiveLessonDraftId'] as String?;
|
||||
adaptiveLessonDraftIndex = data['adaptiveLessonDraftIndex'] as int? ?? 0;
|
||||
adaptiveLessonDraftAnswer =
|
||||
data['adaptiveLessonDraftAnswer'] as String? ?? '';
|
||||
adaptiveLessonDraftReferenceShown =
|
||||
data['adaptiveLessonDraftReferenceShown'] as bool? ?? false;
|
||||
adaptiveLessonDraftUsedVoice =
|
||||
data['adaptiveLessonDraftUsedVoice'] as bool? ?? false;
|
||||
adaptiveLessonDraftTranscriptEdited =
|
||||
data['adaptiveLessonDraftTranscriptEdited'] as bool? ?? false;
|
||||
adaptiveLessonDraftTranscriptConfirmed =
|
||||
data['adaptiveLessonDraftTranscriptConfirmed'] as bool? ?? false;
|
||||
adaptiveLessonDraftOriginalTranscript =
|
||||
data['adaptiveLessonDraftOriginalTranscript'] as String? ?? '';
|
||||
adaptiveLessonDraftRecordingPath =
|
||||
data['adaptiveLessonDraftRecordingPath'] as String?;
|
||||
reportedAdaptiveLessonIds
|
||||
..clear()
|
||||
..addAll(
|
||||
(data['reportedAdaptiveLessonIds'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>(),
|
||||
);
|
||||
final savedTemporaryLexicon = data['temporaryLexicon'] as List<dynamic>?;
|
||||
if (savedTemporaryLexicon != null) {
|
||||
temporaryLexicon
|
||||
..clear()
|
||||
..addEntries(
|
||||
savedTemporaryLexicon
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((item) {
|
||||
final query = item['query'] as String? ?? '';
|
||||
return MapEntry(
|
||||
_normalizeLexiconQuery(query),
|
||||
TemporaryLexiconEntry(
|
||||
query: query,
|
||||
definition: item['definition'] as String? ?? '',
|
||||
provider: item['provider'] as String? ?? 'unknown',
|
||||
model: item['model'] as String? ?? '',
|
||||
createdAt:
|
||||
DateTime.tryParse(item['createdAt'] as String? ?? '') ??
|
||||
DateTime.now(),
|
||||
),
|
||||
);
|
||||
})
|
||||
.where(
|
||||
(entry) =>
|
||||
entry.key.isNotEmpty && entry.value.definition.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
final savedSentenceAnalyses = data['sentenceAnalyses'] as List<dynamic>?;
|
||||
if (savedSentenceAnalyses != null) {
|
||||
sentenceAnalyses
|
||||
..clear()
|
||||
..addEntries(
|
||||
savedSentenceAnalyses
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((item) {
|
||||
final query = item['query'] as String? ?? '';
|
||||
final payload = item['payload'] as Map<String, dynamic>? ?? {};
|
||||
return MapEntry(
|
||||
_normalizeLexiconQuery(query),
|
||||
SentenceAnalysisResult.fromJson(payload),
|
||||
);
|
||||
})
|
||||
.where(
|
||||
(entry) =>
|
||||
entry.key.isNotEmpty && entry.value.translation.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
aiProvider = _enumValue(
|
||||
AiProviderType.values,
|
||||
data['aiProvider'] as String?,
|
||||
aiProvider,
|
||||
);
|
||||
lessonStep = _enumValue(
|
||||
LessonStep.values,
|
||||
data['lessonStep'] as String?,
|
||||
lessonStep,
|
||||
);
|
||||
previewIndex = data['previewIndex'] as int? ?? previewIndex;
|
||||
completedLessons = data['completedLessons'] as int? ?? completedLessons;
|
||||
activeLessonId = data['activeLessonId'] as String? ?? activeLessonId;
|
||||
completedLessonIds
|
||||
..clear()
|
||||
..addAll(
|
||||
(data['completedLessonIds'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>(),
|
||||
);
|
||||
completedSegmentIds
|
||||
..clear()
|
||||
..addAll(
|
||||
(data['completedSegmentIds'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>(),
|
||||
);
|
||||
reportedAiVariantKeys
|
||||
..clear()
|
||||
..addAll(
|
||||
(data['reportedAiVariantKeys'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>(),
|
||||
);
|
||||
final savedSegmentIndexes =
|
||||
data['activeSegmentIndexes'] as Map<String, dynamic>?;
|
||||
if (savedSegmentIndexes != null) {
|
||||
activeSegmentIndexes
|
||||
..clear()
|
||||
..addAll(
|
||||
savedSegmentIndexes.map(
|
||||
(key, value) => MapEntry(key, value as int? ?? 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false;
|
||||
lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false;
|
||||
lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false;
|
||||
lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false;
|
||||
lessonDialogueComplete = data['lessonDialogueComplete'] as bool? ?? false;
|
||||
independentAttemptComplete =
|
||||
data['independentAttemptComplete'] as bool? ?? false;
|
||||
independentAttemptAssisted =
|
||||
data['independentAttemptAssisted'] as bool? ?? false;
|
||||
independentAttemptSpoken =
|
||||
data['independentAttemptSpoken'] as bool? ?? false;
|
||||
lessonWritingDraft = data['lessonWritingDraft'] as String? ?? '';
|
||||
independentAttemptDraft = data['independentAttemptDraft'] as String? ?? '';
|
||||
final reviews = data['reviews'] as List<dynamic>?;
|
||||
if (reviews != null) {
|
||||
reviewQueue
|
||||
..clear()
|
||||
..addAll(
|
||||
reviews.whereType<Map<String, dynamic>>().map(_reviewFromJson),
|
||||
);
|
||||
}
|
||||
final savedEvidence = data['attemptEvidence'] as List<dynamic>?;
|
||||
if (savedEvidence != null) {
|
||||
attemptEvidence
|
||||
..clear()
|
||||
..addAll(
|
||||
savedEvidence.whereType<Map<String, dynamic>>().map(
|
||||
(item) => AttemptEvidence(
|
||||
id: item['id'] as String? ?? '',
|
||||
itemId: item['itemId'] as String? ?? '',
|
||||
taskId: item['taskId'] as String? ?? '',
|
||||
skill: item['skill'] as String? ?? '',
|
||||
inputMode: item['inputMode'] as String? ?? 'text',
|
||||
outcome: _enumValue(
|
||||
EvidenceKind.values,
|
||||
item['outcome'] as String?,
|
||||
EvidenceKind.pending,
|
||||
),
|
||||
createdAt:
|
||||
DateTime.tryParse(item['createdAt'] as String? ?? '') ??
|
||||
DateTime.now(),
|
||||
rawAnswer: item['rawAnswer'] as String?,
|
||||
recordingPath: item['recordingPath'] as String?,
|
||||
assisted: item['assisted'] as bool? ?? false,
|
||||
variantIndex: item['variantIndex'] as int? ?? 0,
|
||||
originalTranscript: item['originalTranscript'] as String?,
|
||||
transcriptConfirmed:
|
||||
item['transcriptConfirmed'] as bool? ?? false,
|
||||
transcriptEdited: item['transcriptEdited'] as bool? ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final savedMastery = data['mastery'] as List<dynamic>?;
|
||||
if (savedMastery != null) {
|
||||
mastery
|
||||
..clear()
|
||||
..addEntries(
|
||||
savedMastery.whereType<Map<String, dynamic>>().map((item) {
|
||||
final id = item['id'] as String;
|
||||
return MapEntry(
|
||||
id,
|
||||
MasteryItem(
|
||||
id: id,
|
||||
label: item['label'] as String? ?? id,
|
||||
status: _enumValue(
|
||||
MasteryStatus.values,
|
||||
item['status'] as String?,
|
||||
MasteryStatus.newItem,
|
||||
),
|
||||
evidence: (item['evidence'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.map(
|
||||
(name) => _enumValue(
|
||||
EvidenceKind.values,
|
||||
name,
|
||||
EvidenceKind.pending,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
needsReview: item['needsReview'] as bool? ?? false,
|
||||
checkpoint: item['checkpoint'] as int? ?? 0,
|
||||
firstTaughtAt: DateTime.tryParse(
|
||||
item['firstTaughtAt'] as String? ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
final savedAssessments = data['assessments'] as List<dynamic>?;
|
||||
if (savedAssessments != null) {
|
||||
assessments
|
||||
..clear()
|
||||
..addAll(
|
||||
savedAssessments.whereType<Map<String, dynamic>>().map((item) {
|
||||
final rawResults =
|
||||
item['results'] as Map<String, dynamic>? ?? const {};
|
||||
return AssessmentRecord(
|
||||
packId: item['packId'] as String,
|
||||
completedAt:
|
||||
DateTime.tryParse(item['completedAt'] as String? ?? '') ??
|
||||
DateTime.now(),
|
||||
results: {
|
||||
for (final skill in AssessmentSkill.values)
|
||||
skill: rawResults[skill.name] == true,
|
||||
},
|
||||
pendingSkills:
|
||||
(item['pendingSkills'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.map(
|
||||
(name) => _enumValue(
|
||||
AssessmentSkill.values,
|
||||
name,
|
||||
AssessmentSkill.speaking,
|
||||
),
|
||||
)
|
||||
.toSet(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
final savedDraft = data['assessmentDraft'] as Map<String, dynamic>?;
|
||||
if (savedDraft != null) {
|
||||
assessmentDraft = AssessmentDraft(
|
||||
packId: savedDraft['packId'] as String,
|
||||
taskIndex: savedDraft['taskIndex'] as int? ?? 0,
|
||||
results: (savedDraft['results'] as Map<String, dynamic>? ?? const {})
|
||||
.map((key, value) => MapEntry(key, value == true)),
|
||||
);
|
||||
}
|
||||
dialogueDraft = _dialogueDraftFromJson(
|
||||
data['dialogueDraft'] as Map<String, dynamic>?,
|
||||
);
|
||||
sceneDialogueDraft = _dialogueDraftFromJson(
|
||||
data['sceneDialogueDraft'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toSnapshotJson() => {
|
||||
'onboardingComplete': onboardingComplete,
|
||||
'goal': goal.name,
|
||||
'placement': placement.name,
|
||||
'dailyMinutes': dailyMinutes,
|
||||
'showChineseHints': showChineseHints,
|
||||
'keepRecordings': keepRecordings,
|
||||
'aiEndpoint': aiEndpoint,
|
||||
'aiModel': aiModel,
|
||||
'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw,
|
||||
'cachedAdaptiveLessonAuditedAt': cachedAdaptiveLessonAuditedAt
|
||||
?.toIso8601String(),
|
||||
'cachedAdaptiveLessonAuditor': cachedAdaptiveLessonAuditor,
|
||||
'adaptiveLessonDraftId': adaptiveLessonDraftId,
|
||||
'adaptiveLessonDraftIndex': adaptiveLessonDraftIndex,
|
||||
'adaptiveLessonDraftAnswer': adaptiveLessonDraftAnswer,
|
||||
'adaptiveLessonDraftReferenceShown': adaptiveLessonDraftReferenceShown,
|
||||
'adaptiveLessonDraftUsedVoice': adaptiveLessonDraftUsedVoice,
|
||||
'adaptiveLessonDraftTranscriptEdited': adaptiveLessonDraftTranscriptEdited,
|
||||
'adaptiveLessonDraftTranscriptConfirmed':
|
||||
adaptiveLessonDraftTranscriptConfirmed,
|
||||
'adaptiveLessonDraftOriginalTranscript':
|
||||
adaptiveLessonDraftOriginalTranscript,
|
||||
'adaptiveLessonDraftRecordingPath': adaptiveLessonDraftRecordingPath,
|
||||
'reportedAdaptiveLessonIds': reportedAdaptiveLessonIds.toList(),
|
||||
'temporaryLexicon': temporaryLexicon.values
|
||||
.map(
|
||||
(entry) => {
|
||||
'query': entry.query,
|
||||
'definition': entry.definition,
|
||||
'provider': entry.provider,
|
||||
'model': entry.model,
|
||||
'createdAt': entry.createdAt.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'sentenceAnalyses': sentenceAnalyses.entries
|
||||
.map(
|
||||
(entry) => {
|
||||
'query': entry.key,
|
||||
'payload': entry.value.toJson(),
|
||||
'provider': entry.value.provider,
|
||||
'model': entry.value.model,
|
||||
'createdAt': entry.value.createdAt.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'aiProvider': aiProvider.name,
|
||||
'lessonStep': lessonStep.name,
|
||||
'previewIndex': previewIndex,
|
||||
'completedLessons': completedLessons,
|
||||
'activeLessonId': activeLessonId,
|
||||
'completedLessonIds': completedLessonIds.toList(),
|
||||
'completedSegmentIds': completedSegmentIds.toList(),
|
||||
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
||||
'activeSegmentIndexes': activeSegmentIndexes,
|
||||
'lessonListeningComplete': lessonListeningComplete,
|
||||
'lessonSpeakingComplete': lessonSpeakingComplete,
|
||||
'lessonReadingComplete': lessonReadingComplete,
|
||||
'lessonWritingComplete': lessonWritingComplete,
|
||||
'lessonDialogueComplete': lessonDialogueComplete,
|
||||
'independentAttemptComplete': independentAttemptComplete,
|
||||
'independentAttemptAssisted': independentAttemptAssisted,
|
||||
'independentAttemptSpoken': independentAttemptSpoken,
|
||||
'lessonWritingDraft': lessonWritingDraft,
|
||||
'independentAttemptDraft': independentAttemptDraft,
|
||||
'reviews': reviewQueue
|
||||
.map(
|
||||
(item) => {
|
||||
'id': item.id,
|
||||
'target': item.target,
|
||||
'prompt': item.prompt,
|
||||
'hint': item.hint,
|
||||
'dueAt': item.dueAt.toIso8601String(),
|
||||
'skill': item.skill,
|
||||
'attempts': item.attempts,
|
||||
'successfulReviews': item.successfulReviews,
|
||||
'variantIndex': item.variantIndex,
|
||||
'lastProgressedAt': item.lastProgressedAt?.toIso8601String(),
|
||||
'isAiGenerated': item.isAiGenerated,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'mastery': mastery.values
|
||||
.map(
|
||||
(item) => {
|
||||
'id': item.id,
|
||||
'label': item.label,
|
||||
'status': item.status.name,
|
||||
'evidence': item.evidence.map((value) => value.name).toList(),
|
||||
'needsReview': item.needsReview,
|
||||
'checkpoint': item.checkpoint,
|
||||
'firstTaughtAt': item.firstTaughtAt?.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'attemptEvidence': attemptEvidence
|
||||
.map(
|
||||
(entry) => {
|
||||
'id': entry.id,
|
||||
'itemId': entry.itemId,
|
||||
'taskId': entry.taskId,
|
||||
'skill': entry.skill,
|
||||
'inputMode': entry.inputMode,
|
||||
'outcome': entry.outcome.name,
|
||||
'createdAt': entry.createdAt.toIso8601String(),
|
||||
'rawAnswer': entry.rawAnswer,
|
||||
'recordingPath': entry.recordingPath,
|
||||
'assisted': entry.assisted,
|
||||
'variantIndex': entry.variantIndex,
|
||||
'originalTranscript': entry.originalTranscript,
|
||||
'transcriptConfirmed': entry.transcriptConfirmed,
|
||||
'transcriptEdited': entry.transcriptEdited,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'assessments': assessments
|
||||
.map(
|
||||
(record) => {
|
||||
'packId': record.packId,
|
||||
'completedAt': record.completedAt.toIso8601String(),
|
||||
'results': {
|
||||
for (final entry in record.results.entries)
|
||||
entry.key.name: entry.value,
|
||||
},
|
||||
'pendingSkills': record.pendingSkills
|
||||
.map((skill) => skill.name)
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'assessmentDraft': assessmentDraft == null
|
||||
? null
|
||||
: {
|
||||
'packId': assessmentDraft!.packId,
|
||||
'taskIndex': assessmentDraft!.taskIndex,
|
||||
'results': assessmentDraft!.results,
|
||||
},
|
||||
'dialogueDraft': _dialogueDraftToJson(dialogueDraft),
|
||||
'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft),
|
||||
};
|
||||
}
|
||||
|
||||
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
|
||||
if (saved == null || saved['lessonId'] is! String) return null;
|
||||
return DialogueDraft(
|
||||
lessonId: saved['lessonId'] as String,
|
||||
stage: saved['stage'] as int? ?? 0,
|
||||
usedHelp: saved['usedHelp'] as bool? ?? false,
|
||||
turns: (saved['turns'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(
|
||||
(turn) => DialogueTurn(
|
||||
text: turn['text'] as String,
|
||||
isLearner: turn['isLearner'] as bool? ?? false,
|
||||
translation: turn['translation'] as String?,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
|
||||
draft == null
|
||||
? null
|
||||
: {
|
||||
'lessonId': draft.lessonId,
|
||||
'stage': draft.stage,
|
||||
'usedHelp': draft.usedHelp,
|
||||
'turns': draft.turns
|
||||
.map(
|
||||
(turn) => {
|
||||
'text': turn.text,
|
||||
'isLearner': turn.isLearner,
|
||||
'translation': turn.translation,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
T _enumValue<T extends Enum>(List<T> values, String? name, T fallback) =>
|
||||
values.where((value) => value.name == name).firstOrNull ?? fallback;
|
||||
|
||||
ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
|
||||
id: data['id'] as String,
|
||||
target: data['target'] as String,
|
||||
prompt: data['prompt'] as String,
|
||||
hint: data['hint'] as String,
|
||||
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
||||
skill: data['skill'] as String,
|
||||
attempts: data['attempts'] as int? ?? 0,
|
||||
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
||||
variantIndex: data['variantIndex'] as int? ?? 0,
|
||||
lastProgressedAt: DateTime.tryParse(
|
||||
data['lastProgressedAt'] as String? ?? '',
|
||||
),
|
||||
isAiGenerated: data['isAiGenerated'] as bool? ?? false,
|
||||
);
|
||||
@@ -155,12 +155,31 @@ AssessmentPack? replacementFor(String packId) => switch (packId) {
|
||||
/// 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 text = input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r"\s+"), " ")
|
||||
.trim();
|
||||
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 has(String word) => words.contains(
|
||||
word
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.trim(),
|
||||
);
|
||||
bool phrase(String value) {
|
||||
final normVal = value
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r"\s+"), " ")
|
||||
.trim();
|
||||
return text.contains(normVal);
|
||||
}
|
||||
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');
|
||||
|
||||
@@ -122,6 +122,8 @@ bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
|
||||
|
||||
String _normalizeAnswer(String value) => value
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
@@ -13,13 +13,20 @@ class ReviewCheckResult {
|
||||
class ReviewFeedback {
|
||||
const ReviewFeedback._();
|
||||
|
||||
static String _normalize(String input) => input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
static ReviewCheckResult check(ReviewItem item, String input) {
|
||||
final text = input.toLowerCase().replaceAll('’', "'");
|
||||
final text = _normalize(input);
|
||||
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 has(String token) => tokens.contains(_normalize(token));
|
||||
bool phrase(String value) => text.contains(_normalize(value));
|
||||
bool hasAny(Iterable<String> values) => values.any(has);
|
||||
final introduction =
|
||||
phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||
@@ -68,9 +75,8 @@ class ReviewFeedback {
|
||||
'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(),
|
||||
),
|
||||
_ when item.id.startsWith('A0-W') =>
|
||||
tokens.contains(_normalize(item.target)) || phrase(item.target),
|
||||
_ => tokens.length >= 2,
|
||||
};
|
||||
return ReviewCheckResult(
|
||||
|
||||
@@ -1081,8 +1081,17 @@ const a0SegmentGrammarNotes = <String, String>{
|
||||
String grammarNoteForSegment(String segmentId, String lessonId) =>
|
||||
a0SegmentGrammarNotes[segmentId] ?? grammarNoteForLesson(lessonId);
|
||||
|
||||
String _normalizeDialogueInput(String response) => response
|
||||
.toLowerCase()
|
||||
.replaceAll('\u2019', "'")
|
||||
.replaceAll('\u2018', "'")
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
final text = response.toLowerCase().replaceAll('’', "'");
|
||||
final text = _normalizeDialogueInput(response);
|
||||
final segment = a0LessonSegments.values
|
||||
.expand((segments) => segments)
|
||||
.where((item) => item.id == segmentId)
|
||||
@@ -1091,7 +1100,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
return false;
|
||||
}
|
||||
bool hasAny(Iterable<String> terms) =>
|
||||
terms.any((term) => text.contains(term));
|
||||
terms.any((term) => text.contains(_normalizeDialogueInput(term)));
|
||||
// A phone-number report needs both the reporting frame and at least one
|
||||
// spoken digit. Other segment turns need one reviewed, task-specific term.
|
||||
if (segmentId == 'a0-04-c' && stage == 0) {
|
||||
@@ -1102,7 +1111,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
}
|
||||
|
||||
bool matchesSegmentIndependent(String segmentId, String response) {
|
||||
final text = response.toLowerCase().replaceAll('’', "'");
|
||||
final text = _normalizeDialogueInput(response);
|
||||
final segment = a0LessonSegments.values
|
||||
.expand((segments) => segments)
|
||||
.where((item) => item.id == segmentId)
|
||||
@@ -1110,17 +1119,18 @@ bool matchesSegmentIndependent(String segmentId, String response) {
|
||||
if (segment == null) return true;
|
||||
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
|
||||
final count = segment.independentRequiredTerms
|
||||
.where((term) => text.contains(term))
|
||||
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
||||
.length;
|
||||
return count >= 3;
|
||||
}
|
||||
if (segmentId == 'a0-04-c') {
|
||||
return segment.independentRequiredTerms
|
||||
.where((term) => text.contains(term))
|
||||
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
||||
.length >=
|
||||
3;
|
||||
}
|
||||
return segment.independentRequiredTerms.any((term) => text.contains(term));
|
||||
return segment.independentRequiredTerms
|
||||
.any((term) => text.contains(_normalizeDialogueInput(term)));
|
||||
}
|
||||
|
||||
class LessonDialogue {
|
||||
@@ -1156,7 +1166,8 @@ bool _containsTerm(String text, String term) {
|
||||
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
|
||||
}
|
||||
if (term.startsWith('#')) return _matchesStructure(text, term);
|
||||
final escaped = RegExp.escape(term.toLowerCase());
|
||||
final normTerm = _normalizeDialogueInput(term);
|
||||
final escaped = RegExp.escape(normTerm);
|
||||
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
|
||||
}
|
||||
|
||||
@@ -1178,9 +1189,6 @@ bool _matchesStructure(String text, String token) => switch (token) {
|
||||
_ => false,
|
||||
};
|
||||
|
||||
String _normalizeDialogueInput(String response) =>
|
||||
response.toLowerCase().replaceAll('\u2019', "'").replaceAll('\u2018', "'");
|
||||
|
||||
/// Whole-lesson and free-scene dialogues validate the same way segment
|
||||
/// dialogues do: the turn has to contain the language the turn is teaching.
|
||||
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
|
||||
|
||||
@@ -14,6 +14,11 @@ import 'sync_service.dart';
|
||||
class SyncCoordinator extends ChangeNotifier {
|
||||
static const _prefKey = 'sync_config_v1';
|
||||
|
||||
/// 合并逻辑版本。升级后丢弃旧的增量同步时间戳,强制全量拉取一次,
|
||||
/// 让旧版本已经跳过的课程位置和复习卡片能被重新合并。
|
||||
static const _mergeVersion = 2;
|
||||
static const _mergeVersionKey = 'mergeVersion';
|
||||
|
||||
static final SyncCoordinator instance = SyncCoordinator._();
|
||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||
|
||||
@@ -49,6 +54,9 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_config = SyncConfig.fromJson(map);
|
||||
if ((map[_mergeVersionKey] as int? ?? 1) < _mergeVersion) {
|
||||
_config = _withoutLastSyncTime(_config);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] init error: $e');
|
||||
@@ -61,12 +69,23 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
Future<void> _saveConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefKey, jsonEncode(_config.toJson()));
|
||||
await prefs.setString(
|
||||
_prefKey,
|
||||
jsonEncode({..._config.toJson(), _mergeVersionKey: _mergeVersion}),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] save config error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static SyncConfig _withoutLastSyncTime(SyncConfig config) => SyncConfig(
|
||||
serverUrl: config.serverUrl,
|
||||
token: config.token,
|
||||
username: config.username,
|
||||
userId: config.userId,
|
||||
autoSyncEnabled: config.autoSyncEnabled,
|
||||
);
|
||||
|
||||
/// 更新服务器地址
|
||||
Future<void> updateServerUrl(String newUrl) async {
|
||||
_config = _config.copyWith(serverUrl: newUrl.trim());
|
||||
@@ -98,7 +117,8 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
|
||||
_config = _withoutLastSyncTime(_config).copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
@@ -132,7 +152,8 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
|
||||
_config = _withoutLastSyncTime(_config).copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../a0_core.dart';
|
||||
import '../models.dart';
|
||||
import '../app_state.dart';
|
||||
import 'sync_models.dart';
|
||||
@@ -20,8 +21,11 @@ class SyncMerger {
|
||||
final masteryUpdates = state.mastery.values.map((m) {
|
||||
// 查找对应复习到期时间
|
||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||
final dueAt = review?.dueAt.toUtc().toIso8601String() ??
|
||||
DateTime.now().toUtc().toIso8601String();
|
||||
final dueAt = (review?.dueAt ??
|
||||
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||
DateTime.now())
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
final attempts = review?.attempts ?? 0;
|
||||
final successfulReviews = review?.successfulReviews ?? 0;
|
||||
|
||||
@@ -71,60 +75,60 @@ class SyncMerger {
|
||||
static bool applyPullResponse(AppState state, SyncPullResponse response) {
|
||||
var changed = false;
|
||||
|
||||
// 1. 合并课程关卡 (Union)
|
||||
// 1. 合并课程关卡 (Union),学习位置只前进不后退
|
||||
if (response.progress != null) {
|
||||
final p = response.progress!;
|
||||
for (final id in p.completedLessonIds) {
|
||||
if (!state.completedLessonIds.contains(id)) {
|
||||
state.completedLessonIds.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (final sid in p.completedSegmentIds) {
|
||||
if (!state.completedSegmentIds.contains(sid)) {
|
||||
state.completedSegmentIds.add(sid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (state.completedLessonIds.length != state.completedLessons) {
|
||||
state.completedLessons = state.completedLessonIds.length;
|
||||
if (state.mergeSyncedLessonProgress(
|
||||
completedLessons: p.completedLessonIds,
|
||||
completedSegments: p.completedSegmentIds,
|
||||
remoteActiveLessonId: p.activeLessonId,
|
||||
)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 合并复习掌握项 (Max Checkpoint)
|
||||
// 2. 合并复习掌握项 (Max Checkpoint;同 Checkpoint 时证据更多者更新)
|
||||
for (final m in response.masteryUpdates) {
|
||||
final local = state.mastery[m.itemId];
|
||||
if (local == null) {
|
||||
// 本地没有,直接添加
|
||||
final status = _parseMasteryStatus(m.status);
|
||||
final evidence = (m.payload['evidence'] as List? ?? [])
|
||||
.whereType<String>()
|
||||
.map(_parseEvidenceKind)
|
||||
.toList();
|
||||
|
||||
final firstTaughtAt = m.payload['firstTaughtAt'] is String
|
||||
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
||||
: null;
|
||||
final needsReview = m.payload['needsReview'] as bool?;
|
||||
if (local == null) {
|
||||
// 本地没有,直接添加
|
||||
state.mastery[m.itemId] = MasteryItem(
|
||||
id: m.itemId,
|
||||
label: m.payload['label'] as String? ?? m.itemId,
|
||||
status: status,
|
||||
evidence: evidence,
|
||||
needsReview: m.payload['needsReview'] as bool? ?? false,
|
||||
needsReview: needsReview ?? false,
|
||||
checkpoint: m.checkpoint,
|
||||
firstTaughtAt: m.payload['firstTaughtAt'] != null
|
||||
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
||||
: null,
|
||||
firstTaughtAt: firstTaughtAt,
|
||||
);
|
||||
changed = true;
|
||||
} else if (m.checkpoint > local.checkpoint) {
|
||||
// 云端 Checkpoint 更高,升级本地状态
|
||||
final status = _parseMasteryStatus(m.status);
|
||||
} else if (m.checkpoint > local.checkpoint ||
|
||||
(m.checkpoint == local.checkpoint &&
|
||||
evidence.length > local.evidence.length)) {
|
||||
// 云端进度更新(证据只追加不删除,条数更多说明学得更多)
|
||||
state.mastery[m.itemId] = local.copyWith(
|
||||
checkpoint: m.checkpoint,
|
||||
status: status,
|
||||
needsReview: m.payload['needsReview'] as bool? ?? local.needsReview,
|
||||
evidence: evidence.length > local.evidence.length ? evidence : null,
|
||||
needsReview: needsReview ?? local.needsReview,
|
||||
firstTaughtAt: local.firstTaughtAt ?? firstTaughtAt,
|
||||
);
|
||||
changed = true;
|
||||
} else if (local.firstTaughtAt == null && firstTaughtAt != null) {
|
||||
state.mastery[m.itemId] = local.copyWith(firstTaughtAt: firstTaughtAt);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (_mergeReviewItem(state, m, firstTaughtAt)) changed = true;
|
||||
}
|
||||
|
||||
// 3. 合并用户偏好设置 (按需合并)
|
||||
@@ -146,6 +150,53 @@ class SyncMerger {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// 与 AppState 引入新课目标时的首次复习间隔保持一致
|
||||
static const _firstReviewDelay = Duration(days: 1);
|
||||
|
||||
/// 首页的到期复习数来自 reviewQueue,因此云端掌握项也要还原为复习卡片
|
||||
static bool _mergeReviewItem(
|
||||
AppState state,
|
||||
SyncMasteryItemPayload m,
|
||||
DateTime? firstTaughtAt,
|
||||
) {
|
||||
// 只有在课程里正式引入过、或复习过的项目才会有复习卡片
|
||||
if (firstTaughtAt == null && m.attempts == 0) return false;
|
||||
// 从未复习过的卡片到期时间固定为首次学习后一天;旧版本客户端会把
|
||||
// 缺失卡片的 due_at 写成推送时刻,这里据此纠正
|
||||
final dueAt = m.attempts == 0 && firstTaughtAt != null
|
||||
? firstTaughtAt.add(_firstReviewDelay)
|
||||
: DateTime.tryParse(m.dueAt);
|
||||
if (dueAt == null) return false;
|
||||
|
||||
final index = state.reviewQueue.indexWhere((r) => r.id == m.itemId);
|
||||
if (index < 0) {
|
||||
final template = coreReviewTemplate(m.itemId);
|
||||
state.reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: m.itemId,
|
||||
target: a0CoreItems[m.itemId] ?? m.itemId,
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: dueAt.toLocal(),
|
||||
skill: template.skill,
|
||||
attempts: m.attempts,
|
||||
successfulReviews: m.successfulReviews,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
final local = state.reviewQueue[index];
|
||||
if (m.attempts > local.attempts) {
|
||||
state.reviewQueue[index] = local.copyWith(
|
||||
dueAt: dueAt.toLocal(),
|
||||
attempts: m.attempts,
|
||||
successfulReviews: m.successfulReviews,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static MasteryStatus _parseMasteryStatus(String str) {
|
||||
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
|
||||
MasteryStatus.newItem;
|
||||
|
||||
@@ -11,18 +11,25 @@ class WritingCheckResult {
|
||||
class WritingFeedback {
|
||||
const WritingFeedback._();
|
||||
|
||||
static String _normalize(String input) => input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
static WritingCheckResult check(
|
||||
String lessonId,
|
||||
String input, {
|
||||
String? segmentId,
|
||||
}) {
|
||||
final text = input.toLowerCase().replaceAll('’', "'");
|
||||
final text = _normalize(input);
|
||||
final words = RegExp(
|
||||
r"[a-z]+(?:'[a-z]+)?",
|
||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||
bool has(String word) => words.contains(word);
|
||||
bool has(String word) => words.contains(_normalize(word));
|
||||
|
||||
bool hasPhrase(String phrase) => text.contains(phrase);
|
||||
bool hasPhrase(String phrase) => text.contains(_normalize(phrase));
|
||||
|
||||
bool hasAny(Iterable<String> choices) => choices.any(has);
|
||||
final hasIntroduction =
|
||||
@@ -40,7 +47,7 @@ class WritingFeedback {
|
||||
'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")),
|
||||
'a0-08-c' => hasItIs && (has('oclock') || hasPhrase("o'clock")),
|
||||
_ => switch (lessonId) {
|
||||
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
|
||||
'a0-02' =>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import '../../core/ai_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
@@ -7,6 +6,7 @@ import '../../core/assessment_bank.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class AssessmentPreparationPage extends StatefulWidget {
|
||||
const AssessmentPreparationPage({
|
||||
@@ -129,16 +129,14 @@ class AssessmentPage extends StatefulWidget {
|
||||
State<AssessmentPage> createState() => _AssessmentPageState();
|
||||
}
|
||||
|
||||
class _AssessmentPageState extends State<AssessmentPage> {
|
||||
class _AssessmentPageState extends State<AssessmentPage>
|
||||
with VoiceAnswerMixin<AssessmentPage> {
|
||||
final controller = TextEditingController();
|
||||
final Map<String, bool> results = {};
|
||||
int index = 0;
|
||||
bool usedMic = false;
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool audioPlayed = false;
|
||||
bool speakingUnavailable = false;
|
||||
AssessmentRecord? completedRecord;
|
||||
@@ -160,10 +158,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
void dispose() {
|
||||
VoiceService.instance.stopSpeaking();
|
||||
VoiceService.instance.stopListening();
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (listening || aiVoiceRecording) {
|
||||
VoiceService.instance.stopRecording();
|
||||
}
|
||||
disposeVoiceAnswer(keepRecording: true);
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -175,62 +170,31 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
if (mounted) setState(() => audioPlayed = true);
|
||||
}
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
Future<void> _mic() async {
|
||||
if (aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
await finishVoiceInput(
|
||||
keepAudio: false,
|
||||
onTranscript: (text) {
|
||||
controller.text = text;
|
||||
usedMic = true;
|
||||
lastTranscript = transcribed.trim();
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
speakingUnavailable = false;
|
||||
}
|
||||
});
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次。')),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
VoiceService.instance.stopSpeaking();
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
final recordStarted = await startVoiceInput(
|
||||
unavailableMessage: '无法访问麦克风,口语可稍后补测。',
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
speakingUnavailable = !recordStarted;
|
||||
});
|
||||
setState(() => speakingUnavailable = !recordStarted);
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,将自动转写为英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
|
||||
);
|
||||
showVoiceMessage('已启动麦克风录音,回答后再次点击,将自动转写为英文。');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,10 @@ import '../../core/seed_courses.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class DialogueScenePage extends StatelessWidget {
|
||||
const DialogueScenePage({
|
||||
super.key,
|
||||
required this.onStart,
|
||||
this.onBack,
|
||||
});
|
||||
const DialogueScenePage({super.key, required this.onStart, this.onBack});
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@@ -105,22 +102,17 @@ class DialoguePage extends StatefulWidget {
|
||||
State<DialoguePage> createState() => _DialoguePageState();
|
||||
}
|
||||
|
||||
class _DialoguePageState extends State<DialoguePage> {
|
||||
class _DialoguePageState extends State<DialoguePage>
|
||||
with VoiceAnswerMixin<DialoguePage> {
|
||||
final controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<DialogueTurn> turns = [];
|
||||
int stage = 0;
|
||||
bool usedHelp = false;
|
||||
String? hint;
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
String? recordingPath;
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
|
||||
@@ -178,7 +170,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
// 3. Check all lesson dialogues
|
||||
for (final dialogue in a0Dialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() ==
|
||||
cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
@@ -188,7 +181,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
// 4. Check all segment dialogues
|
||||
for (final dialogue in a0SegmentDialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() ==
|
||||
cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
@@ -260,17 +254,12 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
void dispose() {
|
||||
VoiceService.instance.stopSpeaking();
|
||||
VoiceService.instance.stopListening();
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (listening || aiVoiceRecording) {
|
||||
VoiceService.instance.stopRecording();
|
||||
}
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
|
||||
_scrollController.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
@@ -283,7 +272,6 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> send() async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
|
||||
@@ -291,7 +279,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
}
|
||||
if (!_matchesCurrentTask(text)) {
|
||||
setState(
|
||||
() => validationError = '这一轮要“${_currentTaskLabel()}”,这句还没做到。'
|
||||
() => validationError =
|
||||
'这一轮要“${_currentTaskLabel()}”,这句还没做到。'
|
||||
'可以点“提示”看示范,再补充一次。',
|
||||
);
|
||||
return;
|
||||
@@ -342,7 +331,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
final replyText = aiResponse?.reply ??
|
||||
final replyText =
|
||||
aiResponse?.reply ??
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: _closingLine);
|
||||
@@ -479,8 +469,9 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
text: turn.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
final finalTrans = (fetched != null && fetched.isNotEmpty)
|
||||
? fetched
|
||||
: "暂无该句中文翻译";
|
||||
setState(() {
|
||||
turns[index] = turn.copyWith(translation: finalTrans);
|
||||
});
|
||||
@@ -523,8 +514,9 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
text: latestAi.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
final finalTrans = (fetched != null && fetched.isNotEmpty)
|
||||
? fetched
|
||||
: "暂无该句中文翻译";
|
||||
setState(() {
|
||||
hint = "对方说:$finalTrans";
|
||||
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
|
||||
@@ -542,102 +534,35 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (listening || aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
if (aiVoiceRecording) {
|
||||
await finishVoiceInput(
|
||||
onTranscript: (text) {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = transcribed.trim();
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
VoiceService.instance.stopSpeaking();
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
if (recordStarted) {
|
||||
setState(() {
|
||||
aiVoiceRecording = true;
|
||||
listening = true;
|
||||
});
|
||||
} else {
|
||||
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,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
afterTranscribe: _scrollToBottom,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await startVoiceInput();
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
void _showHint() {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
final hintIdx = stage < script.hints.length
|
||||
? stage
|
||||
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
|
||||
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
|
||||
});
|
||||
_saveDraft();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _showWord() => showLexiconLookup(
|
||||
@@ -672,137 +597,12 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LexiconText(turn.text, state: widget.state),
|
||||
if (!turn.isLearner) ...[
|
||||
if (_shownTranslations.contains(index) &&
|
||||
turn.translation != null &&
|
||||
turn.translation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
turn.translation!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF2D3748),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
VoiceService.instance.speak(turn.text),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"播放",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _toggleTurnTranslation(index),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_shownTranslations.contains(index)
|
||||
? Icons.translate
|
||||
: Icons.translate_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_shownTranslations.contains(index)
|
||||
? "隐藏翻译"
|
||||
: "翻译",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => showLexiconLookup(
|
||||
context,
|
||||
itemBuilder: (context, index) => _TurnBubble(
|
||||
turn: turns[index],
|
||||
state: widget.state,
|
||||
initialText: turn.text,
|
||||
showTranslation: _shownTranslations.contains(index),
|
||||
onToggleTranslation: () => _toggleTurnTranslation(index),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.psychology_alt_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"句型解析",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!finished) ...[
|
||||
@@ -810,24 +610,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_AssistChip(
|
||||
label: '提示',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
final hintIdx = stage < script.hints.length
|
||||
? stage
|
||||
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
|
||||
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
|
||||
});
|
||||
_saveDraft();
|
||||
_scrollToBottom();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '翻译',
|
||||
onTap: _showLatestAiTranslation,
|
||||
),
|
||||
_AssistChip(label: '提示', onTap: _showHint),
|
||||
_AssistChip(label: '翻译', onTap: _showLatestAiTranslation),
|
||||
_AssistChip(
|
||||
label: '慢一点',
|
||||
onTap: () => _playLatestAi(slow: true),
|
||||
@@ -898,9 +682,7 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle
|
||||
: Icons.mic_none,
|
||||
listening ? Icons.stop_circle : Icons.mic_none,
|
||||
color: listening ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: transcribing ? null : _toggleListening,
|
||||
@@ -919,32 +701,13 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
: '这是设备转写;未修改提交后会作为语音尝试保存。',
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
RecordingControls(
|
||||
recording: recording,
|
||||
playing: playingRecording,
|
||||
hasRecording: recordingPath != null,
|
||||
onToggleRecording: toggleRecording,
|
||||
onPlay: playRecording,
|
||||
onDelete: deleteRecording,
|
||||
),
|
||||
Text(
|
||||
widget.state.keepRecordings
|
||||
@@ -985,6 +748,127 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _TurnBubble extends StatelessWidget {
|
||||
const _TurnBubble({
|
||||
required this.turn,
|
||||
required this.state,
|
||||
required this.showTranslation,
|
||||
required this.onToggleTranslation,
|
||||
});
|
||||
|
||||
final DialogueTurn turn;
|
||||
final AppState state;
|
||||
final bool showTranslation;
|
||||
final VoidCallback onToggleTranslation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final translation = turn.translation;
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LexiconText(turn.text, state: state),
|
||||
if (!turn.isLearner) ...[
|
||||
if (showTranslation &&
|
||||
translation != null &&
|
||||
translation.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
translation,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF2D3748),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_TurnAction(
|
||||
icon: Icons.volume_up_outlined,
|
||||
label: '播放',
|
||||
onTap: () => VoiceService.instance.speak(turn.text),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_TurnAction(
|
||||
icon: showTranslation
|
||||
? Icons.translate
|
||||
: Icons.translate_outlined,
|
||||
label: showTranslation ? '隐藏翻译' : '翻译',
|
||||
onTap: onToggleTranslation,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_TurnAction(
|
||||
icon: Icons.psychology_alt_outlined,
|
||||
label: '句型解析',
|
||||
onTap: () => showLexiconLookup(
|
||||
context,
|
||||
state: state,
|
||||
initialText: turn.text,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TurnAction extends StatelessWidget {
|
||||
const _TurnAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AssistChip extends StatelessWidget {
|
||||
const _AssistChip({required this.label, required this.onTap});
|
||||
final String label;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _IndependentStep extends StatefulWidget {
|
||||
const _IndependentStep({
|
||||
required this.state,
|
||||
required this.segmentId,
|
||||
required this.keepRecording,
|
||||
required this.activity,
|
||||
required this.controller,
|
||||
required this.showHelp,
|
||||
required this.canContinue,
|
||||
required this.onChanged,
|
||||
required this.onNeedHelp,
|
||||
required this.onLookup,
|
||||
required this.onContinue,
|
||||
required this.onLater,
|
||||
});
|
||||
final AppState state;
|
||||
final LessonActivity activity;
|
||||
final String segmentId;
|
||||
final bool keepRecording;
|
||||
final TextEditingController controller;
|
||||
final bool showHelp;
|
||||
final bool canContinue;
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onNeedHelp;
|
||||
final VoidCallback onLookup;
|
||||
final void Function(bool spoken, String? recordingPath) onContinue;
|
||||
final VoidCallback onLater;
|
||||
|
||||
@override
|
||||
State<_IndependentStep> createState() => _IndependentStepState();
|
||||
}
|
||||
|
||||
class _IndependentStepState extends State<_IndependentStep>
|
||||
with VoiceAnswerMixin<_IndependentStep> {
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
String? validationError;
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeVoiceAnswer(keepRecording: widget.keepRecording);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
|
||||
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
|
||||
return;
|
||||
}
|
||||
widget.onContinue(
|
||||
usedVoice && !transcriptEdited,
|
||||
widget.keepRecording ? recordingPath : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() async {
|
||||
if (aiVoiceRecording) {
|
||||
await finishVoiceInput(
|
||||
onTranscript: (text) {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
},
|
||||
afterTranscribe: widget.onChanged,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
widget.onChanged();
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
final recordStarted = await startVoiceInput();
|
||||
if (recordStarted && mounted) {
|
||||
showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) setState(() => listening = ready);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 6,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||||
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(widget.activity.independentPrompt),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'帮助:${widget.activity.independentHelp}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: (value) {
|
||||
if (usedVoice && value != lastTranscript) transcriptEdited = true;
|
||||
setState(() {});
|
||||
widget.onChanged();
|
||||
},
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入完整英文句子',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止录音' : '语音输入',
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
onPressed: _toggleMic,
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
widget.keepRecording
|
||||
? '可录下这次尝试并保存在本机;不会发送给 AI。'
|
||||
: '可录下这次尝试并回听;离开本页后会自动删除。',
|
||||
),
|
||||
RecordingControls(
|
||||
recording: recording,
|
||||
playing: playingRecording,
|
||||
hasRecording: recordingPath != null,
|
||||
onToggleRecording: toggleRecording,
|
||||
onPlay: playRecording,
|
||||
onDelete: deleteRecording,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (usedVoice)
|
||||
SectionCard(
|
||||
tint: transcriptEdited ? AppColors.warm : AppColors.softGreen,
|
||||
child: Text(
|
||||
transcriptEdited
|
||||
? '你修改了设备转写:这次会按文字练习保存,不计口语练习。'
|
||||
: '这是设备转写。未修改并确认后,会保留为本次语音练习记录。',
|
||||
style: TextStyle(
|
||||
color: transcriptEdited ? AppColors.warmInk : AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (validationError != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
validationError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (!widget.showHelp)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onNeedHelp,
|
||||
child: const Text('需要帮助'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onLookup,
|
||||
child: const Text('查词或短语'),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||||
PrimaryButton(
|
||||
label: widget.showHelp ? '带帮助完成' : '独立完成',
|
||||
onPressed: widget.canContinue ? _submit : null,
|
||||
),
|
||||
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _ListeningStep extends StatelessWidget {
|
||||
const _ListeningStep({
|
||||
required this.state,
|
||||
required this.activity,
|
||||
required this.options,
|
||||
required this.correctAnswer,
|
||||
required this.selectedAnswer,
|
||||
required this.audioPlayed,
|
||||
required this.onSelected,
|
||||
required this.onPlayed,
|
||||
required this.onLookup,
|
||||
required this.onContinue,
|
||||
});
|
||||
|
||||
final LessonActivity activity;
|
||||
final AppState state;
|
||||
final List<String> options;
|
||||
final String correctAnswer;
|
||||
|
||||
final int selectedAnswer;
|
||||
final bool audioPlayed;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onPlayed;
|
||||
final VoidCallback onLookup;
|
||||
final VoidCallback? onContinue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final answers = options;
|
||||
return _LessonScaffold(
|
||||
step: 2,
|
||||
child: SpacedColumn(
|
||||
spacing: 14,
|
||||
children: [
|
||||
const Eyebrow('听一听'),
|
||||
Text(
|
||||
activity.listeningQuestion,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: _AudioRow(
|
||||
label: audioPlayed ? '再播放一次' : '播放问题',
|
||||
speech: activity.listening,
|
||||
onPlayed: onPlayed,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: onLookup,
|
||||
icon: const Icon(Icons.menu_book_outlined),
|
||||
label: const Text('查看词或短语'),
|
||||
),
|
||||
if (audioPlayed)
|
||||
SectionCard(child: LexiconText(activity.listening, state: state)),
|
||||
for (var index = 0; index < answers.length; index++)
|
||||
SectionCard(
|
||||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||
onTap: () {
|
||||
onSelected(index);
|
||||
if (!audioPlayed) {
|
||||
onPlayed();
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selectedAnswer == index
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
color: selectedAnswer == index
|
||||
? AppColors.green
|
||||
: AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(answers[index]),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: selectedAnswer >= 0
|
||||
? '检查并继续'
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
|
||||
onPressed: onContinue,
|
||||
),
|
||||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||
const Text(
|
||||
'再听一次,选择正确答案。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _PreviewStep extends StatelessWidget {
|
||||
const _PreviewStep({
|
||||
required this.state,
|
||||
required this.item,
|
||||
required this.position,
|
||||
required this.total,
|
||||
required this.onLookup,
|
||||
required this.onNext,
|
||||
required this.onSkip,
|
||||
});
|
||||
|
||||
final VocabularyItem item;
|
||||
final AppState state;
|
||||
final int position;
|
||||
final int total;
|
||||
final VoidCallback onLookup;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onSkip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 1,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('先认识今天的词 · $position / $total'),
|
||||
Text('后面会遇到这些词。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('先听一遍、知道意思就够了,不用马上背会。'),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
item.word,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (item.ipa != null)
|
||||
Text(item.ipa!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(item.meaning, style: const TextStyle(fontSize: 17)),
|
||||
_AudioRow(label: '播放示范音', speech: item.word),
|
||||
LexiconText(item.example, state: state),
|
||||
Text(item.exampleMeaning),
|
||||
],
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
|
||||
),
|
||||
PrimaryButton(
|
||||
label: position == total ? '进入课程' : '认识了,下一个',
|
||||
onPressed: onNext,
|
||||
),
|
||||
TextButton(onPressed: onSkip, child: const Text('跳过,直接进入课程')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _ReadingStep extends StatefulWidget {
|
||||
const _ReadingStep({
|
||||
required this.state,
|
||||
required this.activity,
|
||||
required this.onLookup,
|
||||
required this.onContinue,
|
||||
});
|
||||
final LessonActivity activity;
|
||||
final AppState state;
|
||||
final VoidCallback onLookup;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_ReadingStep> createState() => _ReadingStepState();
|
||||
}
|
||||
|
||||
class _ReadingStepState extends State<_ReadingStep> {
|
||||
final controller = TextEditingController();
|
||||
int? selectedOptionIndex;
|
||||
bool showAnswer = false;
|
||||
|
||||
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
|
||||
late final List<String> options = shuffledOptions(
|
||||
widget.activity.readingOptions,
|
||||
'${widget.activity.readingQuestion}-reading',
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isOptionCorrect(int index) {
|
||||
if (options.isEmpty || index < 0 || index >= options.length) {
|
||||
return false;
|
||||
}
|
||||
final option = options[index].trim();
|
||||
final answer = widget.activity.readingAnswer.trim();
|
||||
if (option.toLowerCase() == answer.toLowerCase()) return true;
|
||||
|
||||
final normOption = option.toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||
'',
|
||||
);
|
||||
final normAnswer = answer.toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||
'',
|
||||
);
|
||||
return normOption.isNotEmpty &&
|
||||
normAnswer.isNotEmpty &&
|
||||
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
|
||||
}
|
||||
|
||||
bool get isOptionMode => options.isNotEmpty;
|
||||
|
||||
bool get isCorrect {
|
||||
if (isOptionMode) {
|
||||
return selectedOptionIndex != null &&
|
||||
_isOptionCorrect(selectedOptionIndex!);
|
||||
}
|
||||
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||
'',
|
||||
);
|
||||
final response = controller.text.toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
|
||||
'',
|
||||
);
|
||||
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
|
||||
// 也能判对('a' 通过 'A book')。
|
||||
return response.isNotEmpty &&
|
||||
answer.isNotEmpty &&
|
||||
response.contains(answer);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasSelected = selectedOptionIndex != null;
|
||||
final answeredCorrectly = isCorrect;
|
||||
|
||||
return _LessonScaffold(
|
||||
step: 4,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('读一读'),
|
||||
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'对话内容',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
VoiceService.instance.speak(widget.activity.reading),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'朗读对话',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LexiconText(
|
||||
widget.activity.reading,
|
||||
state: widget.state,
|
||||
style: const TextStyle(fontSize: 16, height: 1.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: widget.onLookup,
|
||||
icon: const Icon(Icons.menu_book_outlined, size: 18),
|
||||
label: const Text('查词或短语'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
final lines = widget.activity.reading.split('\n');
|
||||
final target = lines
|
||||
.firstWhere(
|
||||
(l) => l.trim().isNotEmpty,
|
||||
orElse: () => widget.activity.reading,
|
||||
)
|
||||
.replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
|
||||
showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: target,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.auto_stories_outlined, size: 18),
|
||||
label: const Text('句型深度解析'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'问题',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.activity.readingQuestion,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOptionMode) ...[
|
||||
for (var index = 0; index < options.length; index++) ...[
|
||||
SectionCard(
|
||||
tint: selectedOptionIndex == index
|
||||
? (_isOptionCorrect(index)
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedOptionIndex = index;
|
||||
showAnswer = false;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selectedOptionIndex == index
|
||||
? (_isOptionCorrect(index)
|
||||
? Icons.check_circle
|
||||
: Icons.cancel_outlined)
|
||||
: Icons.radio_button_off,
|
||||
color: selectedOptionIndex == index
|
||||
? (_isOptionCorrect(index)
|
||||
? AppColors.green
|
||||
: AppColors.warmInk)
|
||||
: AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
options[index],
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: selectedOptionIndex == index
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: selectedOptionIndex == index
|
||||
? (_isOptionCorrect(index)
|
||||
? AppColors.green
|
||||
: AppColors.warmInk)
|
||||
: AppColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasSelected && answeredCorrectly)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.green.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: AppColors.green, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'回答正确!点击下方按钮继续',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (hasSelected && !answeredCorrectly)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warm,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.warmInk.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
color: AppColors.warmInk,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'不对哦,再仔细观察对话中的关键句子~',
|
||||
style: TextStyle(
|
||||
color: AppColors.warmInk,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '用英文输入答案',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showAnswer)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'答案:${widget.activity.readingAnswer}',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (!showAnswer &&
|
||||
((isOptionMode && hasSelected && !answeredCorrectly) ||
|
||||
(!isOptionMode &&
|
||||
controller.text.isNotEmpty &&
|
||||
!answeredCorrectly)))
|
||||
TextButton(
|
||||
onPressed: () => setState(() => showAnswer = true),
|
||||
child: const Text('查看答案后继续学习'),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: showAnswer || answeredCorrectly
|
||||
? '继续写一写'
|
||||
: (isOptionMode ? '请选择答案' : '检查并继续'),
|
||||
onPressed: showAnswer || answeredCorrectly
|
||||
? widget.onContinue
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _SpeakingStep extends StatefulWidget {
|
||||
const _SpeakingStep({
|
||||
required this.state,
|
||||
required this.text,
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String text;
|
||||
final AppState state;
|
||||
final bool keepRecording;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<_SpeakingStep> createState() => _SpeakingStepState();
|
||||
}
|
||||
|
||||
class _SpeakingStepState extends State<_SpeakingStep>
|
||||
with VoiceAnswerMixin<_SpeakingStep> {
|
||||
String transcript = '';
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeVoiceAnswer(keepRecording: widget.keepRecording);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() async {
|
||||
if (aiVoiceRecording) {
|
||||
await finishVoiceInput(
|
||||
noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。',
|
||||
onTranscript: (text) => transcript = text,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
if (!widget.keepRecording) {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
|
||||
final recordStarted = await startVoiceInput();
|
||||
if (!recordStarted || !mounted) return;
|
||||
setState(() => recordingPath = null);
|
||||
showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。');
|
||||
}
|
||||
|
||||
Future<void> _togglePlayRecording() async {
|
||||
if (playingRecording) {
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
return;
|
||||
}
|
||||
if (recordingPath == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。')));
|
||||
return;
|
||||
}
|
||||
await playRecording();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 3,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('跟读'),
|
||||
Text('先听,再说。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
LexiconText(
|
||||
widget.text,
|
||||
state: widget.state,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'/es - eɪtʃ - iː - en/',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: _AudioRow(label: '播放示范音', speech: widget.text),
|
||||
),
|
||||
const SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'字母之间留一个短停顿。先清楚,不必快。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
|
||||
onPressed: transcribing ? null : _toggleMic,
|
||||
),
|
||||
SecondaryButton(
|
||||
label: playingRecording ? '停止播放' : '播放跟读',
|
||||
onPressed: (listening || transcribing) ? null : _togglePlayRecording,
|
||||
),
|
||||
if (recordingPath != null)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: (listening || transcribing)
|
||||
? null
|
||||
: _togglePlayRecording,
|
||||
icon: Icon(
|
||||
playingRecording ? Icons.stop : Icons.play_arrow,
|
||||
),
|
||||
label: Text(playingRecording ? '停止播放' : '播放跟读'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: (listening || transcribing)
|
||||
? null
|
||||
: deleteRecording,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (transcript.isNotEmpty)
|
||||
SectionCard(child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。")),
|
||||
const SectionCard(
|
||||
child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
||||
onPressed: widget.onContinue,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
part of '../lesson_flow.dart';
|
||||
|
||||
class _WritingStep extends StatefulWidget {
|
||||
const _WritingStep({
|
||||
required this.state,
|
||||
required this.lessonId,
|
||||
required this.segmentId,
|
||||
required this.activity,
|
||||
required this.controller,
|
||||
required this.showHelp,
|
||||
required this.canContinue,
|
||||
required this.onChanged,
|
||||
required this.onToggleHelp,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String lessonId;
|
||||
final AppState state;
|
||||
final String segmentId;
|
||||
final LessonActivity activity;
|
||||
final TextEditingController controller;
|
||||
final bool showHelp;
|
||||
final bool canContinue;
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onToggleHelp;
|
||||
final ValueChanged<bool> onContinue;
|
||||
|
||||
@override
|
||||
State<_WritingStep> createState() => _WritingStepState();
|
||||
}
|
||||
|
||||
class _WritingStepState extends State<_WritingStep> {
|
||||
WritingCheckResult? result;
|
||||
WritingAiFeedback? aiFeedback;
|
||||
String? aiFeedbackError;
|
||||
bool requestingAiFeedback = false;
|
||||
|
||||
void _checkOrContinue() {
|
||||
if (result?.complete == true) {
|
||||
widget.onContinue(aiFeedback != null);
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
() => result = WritingFeedback.check(
|
||||
widget.lessonId,
|
||||
widget.controller.text,
|
||||
segmentId: widget.segmentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAiFeedback() async {
|
||||
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
requestingAiFeedback = true;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.writingFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lessonId: widget.lessonId,
|
||||
taskPrompt: widget.activity.writingPrompt,
|
||||
answer: widget.controller.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
requestingAiFeedback = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 5,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('写一写'),
|
||||
Text(
|
||||
widget.activity.writingPrompt,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(
|
||||
'小提示:${grammarNoteForSegment(widget.segmentId, widget.lessonId)}',
|
||||
),
|
||||
),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text(
|
||||
widget.activity.writingExample,
|
||||
style: TextStyle(fontSize: 18, height: 1.5),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
result = null;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
widget.onChanged();
|
||||
},
|
||||
minLines: 3,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: '你的答案',
|
||||
hintText: widget.showHelp
|
||||
? widget.activity.writingExample
|
||||
: '请输入完整英文答案',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onToggleHelp,
|
||||
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.canContinue && !requestingAiFeedback
|
||||
? _requestAiFeedback
|
||||
: null,
|
||||
icon: requestingAiFeedback
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'),
|
||||
),
|
||||
if (aiFeedback != null)
|
||||
SectionCard(
|
||||
tint: aiFeedback!.verdict == 'accepted'
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('AI 建议:${aiFeedback!.feedback}'),
|
||||
if (aiFeedback!.missing.isNotEmpty)
|
||||
Text('还可补充:${aiFeedback!.missing.join('、')}'),
|
||||
if (aiFeedback!.suggestion != null)
|
||||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||
const Text(
|
||||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (aiFeedbackError != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
aiFeedbackError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (result != null)
|
||||
SectionCard(
|
||||
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
||||
child: Text(
|
||||
result!.message,
|
||||
style: TextStyle(
|
||||
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
||||
),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
||||
onPressed: widget.canContinue ? _checkOrContinue : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../core/review_feedback.dart';
|
||||
import '../../core/a0_core.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class ReviewPage extends StatefulWidget {
|
||||
const ReviewPage({
|
||||
@@ -313,21 +314,16 @@ class AdaptiveLessonPage extends StatefulWidget {
|
||||
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
|
||||
}
|
||||
|
||||
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
with VoiceAnswerMixin<AdaptiveLessonPage> {
|
||||
final controller = TextEditingController();
|
||||
int index = 0;
|
||||
bool showReference = false;
|
||||
String? answerFeedback;
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
bool transcriptConfirmed = false;
|
||||
String lastTranscript = '';
|
||||
bool recording = false;
|
||||
bool playingRecording = false;
|
||||
String? recordingPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -353,13 +349,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
void dispose() {
|
||||
VoiceService.instance.stopSpeaking();
|
||||
VoiceService.instance.stopListening();
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (listening || aiVoiceRecording) {
|
||||
VoiceService.instance.stopRecording();
|
||||
}
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -428,103 +418,30 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (listening || aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
if (aiVoiceRecording) {
|
||||
await finishVoiceInput(
|
||||
keepAudio: false,
|
||||
onTranscript: (text) {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = transcribed.trim();
|
||||
}
|
||||
});
|
||||
lastTranscript = text;
|
||||
},
|
||||
afterTranscribe: () {
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次或输入文本。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
VoiceService.instance.stopSpeaking();
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
if (recordStarted) {
|
||||
setState(() {
|
||||
aiVoiceRecording = true;
|
||||
listening = true;
|
||||
});
|
||||
} else {
|
||||
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,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
await startVoiceInput(
|
||||
unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -682,33 +599,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
? '可录音回听并仅保存在本机;不会发送给 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),
|
||||
),
|
||||
],
|
||||
],
|
||||
RecordingControls(
|
||||
recording: recording,
|
||||
playing: playingRecording,
|
||||
hasRecording: recordingPath != null,
|
||||
onToggleRecording: listening ? null : toggleRecording,
|
||||
onPlay: playRecording,
|
||||
onDelete: deleteRecording,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -21,10 +21,20 @@ List<VocabularyItem> get courseLexiconEntries {
|
||||
|
||||
/// 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();
|
||||
final normalized = text
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (normalized.isEmpty) return null;
|
||||
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
|
||||
final word = entry!.word.toLowerCase().replaceAll('’', "'");
|
||||
final word = entry!.word
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (normalized == word) return true;
|
||||
final pattern = RegExp(
|
||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
||||
@@ -36,12 +46,22 @@ VocabularyItem? findCourseLexicon(String text) {
|
||||
|
||||
/// Extracts all distinct course lexicon phrases/words that appear within [text].
|
||||
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
|
||||
final normalized = text.toLowerCase().replaceAll('’', "'").trim();
|
||||
final normalized = text
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (normalized.isEmpty) return const [];
|
||||
final matched = <VocabularyItem>[];
|
||||
final seen = <String>{};
|
||||
for (final item in courseLexiconEntries) {
|
||||
final word = item.word.toLowerCase().replaceAll('’', "'");
|
||||
final word = item.word
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (word.isEmpty || word == normalized) continue;
|
||||
final pattern = RegExp(
|
||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/ai_service.dart';
|
||||
import '../core/app_state.dart';
|
||||
import '../core/voice_service.dart';
|
||||
|
||||
/// Microphone handling shared by pages where the learner speaks an answer.
|
||||
///
|
||||
/// Two independent flows use the microphone:
|
||||
/// * voice input records an answer and transcribes it into text
|
||||
/// ([aiVoiceRecording], [listening], [transcribing]);
|
||||
/// * the playback recorder keeps an attempt to listen back to
|
||||
/// ([recording], [playingRecording], [recordingPath]).
|
||||
mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
||||
static const noSpeechMessage = '未识别到清晰语音,请再试一次或直接输入文字。';
|
||||
static const micUnavailableMessage = '无法访问麦克风,请检查手机录音权限。';
|
||||
|
||||
/// Supplies the AI configuration used for transcription.
|
||||
AppState get voiceState;
|
||||
|
||||
/// The microphone is recording an answer to transcribe. A page that also
|
||||
/// uses device speech recognition sets [listening] alone for that.
|
||||
bool aiVoiceRecording = false;
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool recording = false;
|
||||
bool playingRecording = false;
|
||||
String? recordingPath;
|
||||
|
||||
void showVoiceMessage(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
/// Starts recording an answer for transcription and reports whether the
|
||||
/// microphone started.
|
||||
Future<bool> startVoiceInput({
|
||||
String unavailableMessage = micUnavailableMessage,
|
||||
}) async {
|
||||
final started = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return started;
|
||||
if (started) {
|
||||
setState(() {
|
||||
aiVoiceRecording = true;
|
||||
listening = true;
|
||||
});
|
||||
} else {
|
||||
showVoiceMessage(unavailableMessage);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
/// Stops the answer recording and transcribes it.
|
||||
///
|
||||
/// [onTranscript] runs inside `setState` with the trimmed, non-empty text.
|
||||
/// [afterTranscribe] runs once transcription has finished, whether or not
|
||||
/// anything was recognized. With [keepAudio] the answer audio also becomes
|
||||
/// the playback [recordingPath]; otherwise it is deleted once transcribed.
|
||||
Future<void> finishVoiceInput({
|
||||
required void Function(String text) onTranscript,
|
||||
VoidCallback? afterTranscribe,
|
||||
bool keepAudio = true,
|
||||
String noSpeech = noSpeechMessage,
|
||||
}) async {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) {
|
||||
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = path != null;
|
||||
if (keepAudio) recordingPath = path;
|
||||
});
|
||||
if (path == null) return;
|
||||
final config = voiceState.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
// Without [keepAudio] the audio only served transcription.
|
||||
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
|
||||
if (!mounted) return;
|
||||
final text = transcribed?.trim() ?? '';
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (text.isNotEmpty) onTranscript(text);
|
||||
});
|
||||
afterTranscribe?.call();
|
||||
if (text.isEmpty) showVoiceMessage(noSpeech);
|
||||
}
|
||||
|
||||
/// Starts a playback recording, replacing the previous one, or stops it.
|
||||
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) showVoiceMessage('无法使用麦克风录音;请检查系统权限。');
|
||||
}
|
||||
|
||||
Future<void> playRecording() async {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(
|
||||
path,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteRecording() async {
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recordingPath = null;
|
||||
playingRecording = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the microphone and player; call from `dispose`. Unless
|
||||
/// [keepRecording], the recorded attempt is deleted.
|
||||
void disposeVoiceAnswer({required bool keepRecording}) {
|
||||
final voice = VoiceService.instance;
|
||||
voice.stopRecordingPlayback();
|
||||
if (listening && !aiVoiceRecording) voice.stopListening();
|
||||
if (aiVoiceRecording || recording) {
|
||||
voice.stopRecording().then((path) {
|
||||
if (!keepRecording) voice.deleteRecording(path);
|
||||
});
|
||||
}
|
||||
if (!keepRecording) voice.deleteRecording(recordingPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record / play back / delete buttons for [VoiceAnswerMixin]'s playback
|
||||
/// recorder.
|
||||
class RecordingControls extends StatelessWidget {
|
||||
const RecordingControls({
|
||||
super.key,
|
||||
required this.recording,
|
||||
required this.playing,
|
||||
required this.hasRecording,
|
||||
required this.onToggleRecording,
|
||||
required this.onPlay,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final bool recording;
|
||||
final bool playing;
|
||||
final bool hasRecording;
|
||||
|
||||
/// Null disables the record button.
|
||||
final VoidCallback? onToggleRecording;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onToggleRecording,
|
||||
icon: Icon(
|
||||
recording
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.fiber_manual_record,
|
||||
),
|
||||
label: Text(recording ? '停止录音' : '录音回听'),
|
||||
),
|
||||
),
|
||||
if (hasRecording) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: playing ? '正在播放' : '回听录音',
|
||||
onPressed: playing ? null : onPlay,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,25 @@ void main() {
|
||||
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
|
||||
});
|
||||
|
||||
test('输入校验忽略多余空格与大小写', () {
|
||||
expect(
|
||||
matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues["a0-02"]!, 1, " S H E N "),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('给 AI 的可用词表随课程递增且不越界', () {
|
||||
final first = taughtLanguageUpTo('a0-01');
|
||||
final later = taughtLanguageUpTo('a0-09');
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/generated_content.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
void main() {
|
||||
const valid = '''{
|
||||
@@ -35,6 +41,54 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'review variant request asks for exactly the fields it accepts',
|
||||
() async {
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final prompts = <String>[];
|
||||
final variant = await http.runWithClient(
|
||||
() => AiService.instance.generateReviewVariant(
|
||||
provider: AiProviderType.openAi,
|
||||
endpoint: 'https://example.test/v1',
|
||||
model: 'test-model',
|
||||
targetItemId: 'A0-P12',
|
||||
basePrompt: '请用英语说你来自哪里。',
|
||||
),
|
||||
() => MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
final messages = body['messages'] as List<dynamic>;
|
||||
prompts.add((messages.single as Map<String, dynamic>)['content']);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': valid},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(variant, isNotNull);
|
||||
expect(prompts, hasLength(1));
|
||||
for (final field in [
|
||||
'schemaVersion',
|
||||
'variantId',
|
||||
'targetItemId',
|
||||
'prompt',
|
||||
'expectedAnswer',
|
||||
]) {
|
||||
expect(prompts.single, contains(field));
|
||||
}
|
||||
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
|
||||
expect(prompts.single, isNot(contains(field)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const writing = '''{
|
||||
"schemaVersion":"writing-feedback-1",
|
||||
"lessonId":"a0-06",
|
||||
|
||||
@@ -79,4 +79,31 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores multiple spaces and casing across review items', () {
|
||||
// Multi-space and uppercase on A0-P03
|
||||
expect(
|
||||
ReviewFeedback.check(
|
||||
review('A0-P03', 'Nice to meet you.'),
|
||||
' NICE TO MEET YOU! ',
|
||||
).complete,
|
||||
isTrue,
|
||||
);
|
||||
// Multi-space and uppercase on A0-P08
|
||||
expect(
|
||||
ReviewFeedback.check(
|
||||
review('A0-P08', 'My number is ...'),
|
||||
'MY NUMBER IS ONE TWO THREE',
|
||||
).complete,
|
||||
isTrue,
|
||||
);
|
||||
// Multi-space on word item
|
||||
expect(
|
||||
ReviewFeedback.check(
|
||||
review('A0-W08', 'three'),
|
||||
' THREE ',
|
||||
).complete,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,130 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMerger restores home page position on a fresh device', () {
|
||||
// Mirrors what the server held for a real account: lesson a0-01 was
|
||||
// finished on one device, then another device pushed its stale position
|
||||
// (active lesson a0-01, review due_at overwritten with the push time).
|
||||
SyncPullResponse stalePull() => const SyncPullResponse(
|
||||
serverTime: '2026-09-16T08:00:00Z',
|
||||
progress: SyncProgressPayload(
|
||||
activeLessonId: 'a0-01',
|
||||
completedLessonIds: ['a0-01'],
|
||||
completedSegmentIds: ['a0-01-a'],
|
||||
updatedAt: '2026-09-16T07:54:27Z',
|
||||
),
|
||||
masteryUpdates: [
|
||||
SyncMasteryItemPayload(
|
||||
itemId: 'A0-P03',
|
||||
checkpoint: 0,
|
||||
status: 'recall',
|
||||
dueAt: '2026-09-16T07:55:13Z',
|
||||
payload: {
|
||||
'label': 'A0-P03',
|
||||
'evidence': ['exposure', 'assisted', 'independentSuccess'],
|
||||
'firstTaughtAt': '2026-09-16T07:49:45Z',
|
||||
},
|
||||
updatedAt: '2026-09-16T07:54:27Z',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
test('advances past lessons completed on another device', () {
|
||||
final state = AppState();
|
||||
state.lessonStep = LessonStep.speaking;
|
||||
|
||||
final changed = SyncMerger.applyPullResponse(state, stalePull());
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
expect(state.activeLessonId, 'a0-02');
|
||||
expect(state.activeSegmentIndexFor('a0-01'), 0);
|
||||
expect(state.lessonStep, LessonStep.preview);
|
||||
});
|
||||
|
||||
test('keeps a completed lesson the learner reopened locally', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
|
||||
state.completedLessons = 2;
|
||||
state.activeLessonId = 'a0-01';
|
||||
|
||||
SyncMerger.applyPullResponse(state, stalePull());
|
||||
|
||||
expect(state.activeLessonId, 'a0-01');
|
||||
});
|
||||
|
||||
test('never moves the position backwards', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
|
||||
state.activeLessonId = 'a0-03';
|
||||
|
||||
SyncMerger.applyPullResponse(state, stalePull());
|
||||
|
||||
expect(state.activeLessonId, 'a0-03');
|
||||
});
|
||||
|
||||
test('adopts a further remote lesson and segment position', () {
|
||||
final state = AppState();
|
||||
final pull = SyncPullResponse(
|
||||
serverTime: '2026-09-16T08:00:00Z',
|
||||
progress: SyncProgressPayload(
|
||||
activeLessonId: 'a0-04',
|
||||
completedLessonIds: const ['a0-01', 'a0-02', 'a0-03'],
|
||||
completedSegmentIds: const ['a0-01-a', 'a0-02-a', 'a0-03-a', 'a0-04-a'],
|
||||
updatedAt: '2026-09-16T07:54:27Z',
|
||||
),
|
||||
);
|
||||
|
||||
SyncMerger.applyPullResponse(state, pull);
|
||||
|
||||
expect(state.activeLessonId, 'a0-04');
|
||||
expect(state.activeSegmentIndexFor('a0-04'), 1);
|
||||
});
|
||||
|
||||
test('rebuilds review cards and repairs never-reviewed due dates', () {
|
||||
final state = AppState();
|
||||
|
||||
SyncMerger.applyPullResponse(state, stalePull());
|
||||
|
||||
final review = state.reviewQueue.singleWhere((r) => r.id == 'A0-P03');
|
||||
expect(
|
||||
review.dueAt.toUtc(),
|
||||
DateTime.parse('2026-09-17T07:49:45Z'),
|
||||
);
|
||||
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
|
||||
});
|
||||
|
||||
test('equal checkpoint with more remote evidence updates local status', () {
|
||||
final state = AppState();
|
||||
state.mastery['A0-P03'] = const MasteryItem(
|
||||
id: 'A0-P03',
|
||||
label: 'A0-P03',
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: [EvidenceKind.exposure],
|
||||
);
|
||||
|
||||
SyncMerger.applyPullResponse(state, stalePull());
|
||||
|
||||
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
|
||||
expect(state.mastery['A0-P03']?.evidence.length, 3);
|
||||
});
|
||||
|
||||
test('push without a review card derives due date from first teaching', () {
|
||||
final state = AppState();
|
||||
state.mastery['A0-P01'] = MasteryItem(
|
||||
id: 'A0-P01',
|
||||
label: 'A0-P01',
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
firstTaughtAt: DateTime.parse('2026-09-16T07:49:45Z'),
|
||||
);
|
||||
|
||||
final req = SyncMerger.buildPushRequest(state);
|
||||
|
||||
expect(req.masteryUpdates.single.dueAt, '2026-09-17T07:49:45.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncService HTTP operations', () {
|
||||
test('testConnection returns true on 200 health check', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
@@ -278,7 +402,7 @@ void main() {
|
||||
|
||||
group('SyncCoordinator Integration', () {
|
||||
test('login, syncNow and logout lifecycle', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
Future<http.Response> handler(http.Request request) async {
|
||||
if (request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@@ -325,9 +449,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
}
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final service = SyncService(client: MockClient(handler));
|
||||
final coordinator = SyncCoordinator.createForTesting(service: service);
|
||||
await coordinator.init();
|
||||
|
||||
@@ -349,6 +473,29 @@ void main() {
|
||||
expect(coordinator.lastSyncTime, isNotNull);
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
|
||||
final pullQueries = <Map<String, String>>[];
|
||||
final relogCoordinator = SyncCoordinator.createForTesting(
|
||||
service: SyncService(
|
||||
client: MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/sync/pull') {
|
||||
pullQueries.add(request.url.queryParameters);
|
||||
}
|
||||
return handler(request);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await relogCoordinator.init();
|
||||
await relogCoordinator.syncNow(AppState());
|
||||
expect(relogCoordinator.lastSyncTime, isNotNull);
|
||||
await relogCoordinator.login(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'tester',
|
||||
password: 'password123',
|
||||
);
|
||||
await relogCoordinator.syncNow(AppState());
|
||||
// A fresh login must pull everything, not only changes since last sync.
|
||||
expect(pullQueries.last.containsKey('since'), isFalse);
|
||||
|
||||
await coordinator.logout();
|
||||
expect(coordinator.isLoggedIn, isFalse);
|
||||
expect(coordinator.config.token, isNull);
|
||||
|
||||
@@ -31,4 +31,31 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores multiple spaces, mixed casing and curly apostrophes', () {
|
||||
// a0-01 greeting and name with multiple spaces and uppercase
|
||||
expect(
|
||||
WritingFeedback.check('a0-01', ' HELLO I AM ALEX ').complete,
|
||||
isTrue,
|
||||
);
|
||||
// a0-01 with curly quote and multiple spaces
|
||||
expect(
|
||||
WritingFeedback.check('a0-01', 'HI I’M MIA').complete,
|
||||
isTrue,
|
||||
);
|
||||
// a0-07 with multiple spaces in phrase
|
||||
expect(
|
||||
WritingFeedback.check('a0-07', 'THIS IS MY MOTHER').complete,
|
||||
isTrue,
|
||||
);
|
||||
// a0-08-c with multiple spaces and curly apostrophe
|
||||
expect(
|
||||
WritingFeedback.check(
|
||||
'a0-08-c',
|
||||
'IT’S THREE O’CLOCK',
|
||||
segmentId: 'a0-08-c',
|
||||
).complete,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user