feat: 增加AI配置文件与TTS自动朗读,修复二级页面返回按钮与音频播放

This commit is contained in:
shenlei
2026-09-15 19:11:39 +09:00
parent 37c86f7ecb
commit a717365c10
18 changed files with 1322 additions and 397 deletions
@@ -33,12 +33,13 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
<!-- Required to query activities that can process text, speech recognition, and TTS engines on Android 11+:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
@@ -0,0 +1,7 @@
{
"provider": "compatible",
"endpoint": "https://codex.slcydia.fun/v1",
"model": "gemini-3.7-flash-high",
"apiKey": "***REMOVED***",
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
}
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
set -e
# 进入项目根目录
cd "$(dirname "$0")"
echo "📱 检查连接的 Android 设备..."
DEVICE_COUNT=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ')
if [ "$DEVICE_COUNT" -eq 0 ]; then
echo "❌ 未检测到连接的 Android 设备,请确保手机已开启 USB 调试并通过数据线连接!"
exit 1
fi
echo "📦 正在编译 Debug APK (包含最新的 assets 配置)..."
flutter build apk --debug
APK_PATH="build/app/outputs/flutter-apk/app-debug.apk"
if [ ! -f "$APK_PATH" ]; then
echo "❌ 找不到编译输出的 APK: $APK_PATH"
exit 1
fi
echo "🚀 正在安装 APK 到手机..."
adb install -r "$APK_PATH"
echo "▶️ 正在手机上启动 口语英语 App..."
adb shell monkey -p com.shen.kouyu_english -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 || true
echo "✅ 安装并启动成功!"
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'models.dart';
class AiConfigFile {
const AiConfigFile({
required this.provider,
required this.endpoint,
required this.model,
this.apiKey,
this.description,
});
final AiProviderType provider;
final String endpoint;
final String model;
final String? apiKey;
final String? description;
static const String defaultAssetPath = 'assets/config/ai_config.json';
factory AiConfigFile.fromJson(Map<String, dynamic> json) {
final providerStr = json['provider'] as String? ?? 'compatible';
final provider = AiProviderType.values.firstWhere(
(p) => p.name.toLowerCase() == providerStr.toLowerCase(),
orElse: () => AiProviderType.compatible,
);
return AiConfigFile(
provider: provider,
endpoint: (json['endpoint'] as String? ?? '').trim(),
model: (json['model'] as String? ?? '').trim(),
apiKey: json['apiKey'] as String?,
description: json['description'] as String?,
);
}
factory AiConfigFile.parse(String rawJson) {
final data = jsonDecode(rawJson) as Map<String, dynamic>;
return AiConfigFile.fromJson(data);
}
Map<String, dynamic> toJson() => {
'provider': provider.name,
'endpoint': endpoint,
'model': model,
if (apiKey != null) 'apiKey': apiKey,
if (description != null) 'description': description,
};
static Future<AiConfigFile?> loadFromAsset([
String path = defaultAssetPath,
]) async {
try {
final content = await rootBundle.loadString(path);
return AiConfigFile.parse(content);
} catch (_) {
return null;
}
}
AiConfigFile copyWith({
AiProviderType? provider,
String? endpoint,
String? model,
String? apiKey,
String? description,
}) => AiConfigFile(
provider: provider ?? this.provider,
endpoint: endpoint ?? this.endpoint,
model: model ?? this.model,
apiKey: apiKey ?? this.apiKey,
description: description ?? this.description,
);
}
+293 -201
View File
@@ -14,12 +14,17 @@ class AiConnectionResult {
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
/// use the OpenAI chat-completions shape (/v1/chat/completions), including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
String? _fallbackApiKey;
void setFallbackApiKey(String? key) {
_fallbackApiKey = key?.trim();
}
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
@@ -29,8 +34,55 @@ class AiService {
}
}
Future<String?> resolveApiKey([String? explicit]) async {
if (explicit != null && explicit.trim().isNotEmpty) {
return explicit.trim();
}
try {
final stored = await _secureStorage.read(key: _keyName);
if (stored != null && stored.trim().isNotEmpty) {
return stored.trim();
}
} catch (_) {}
if (_fallbackApiKey != null && _fallbackApiKey!.trim().isNotEmpty) {
return _fallbackApiKey!.trim();
}
return null;
}
Future<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
(await resolveApiKey())?.isNotEmpty ?? false;
Future<String?> getApiKey() async => await resolveApiKey();
/// Resolves the target endpoint URI. For OpenAI and compatible endpoints,
/// all requests target the standard Chat Completions endpoint (/v1/chat/completions).
static Uri? resolveEndpointUri({
required AiProviderType provider,
required String endpoint,
required String model,
}) {
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
if (base.isEmpty) return null;
if (provider == AiProviderType.gemini) {
if (base.contains(':generateContent')) {
return Uri.tryParse(base);
}
return Uri.tryParse('$base/models/$model:generateContent');
}
if (base.endsWith('/chat/completions')) {
return Uri.tryParse(base);
}
if (base.endsWith('/v1')) {
return Uri.tryParse('$base/chat/completions');
}
if (base.endsWith('/responses')) {
return Uri.tryParse(
base.replaceFirst(RegExp(r'/responses$'), '/chat/completions'),
);
}
return Uri.tryParse('$base/v1/chat/completions');
}
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
@@ -45,18 +97,17 @@ class AiService {
text.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
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 != 'https' && uri.scheme != 'http')) {
return null;
}
const instruction =
@@ -117,15 +168,16 @@ class AiService {
required AiProviderType provider,
required String endpoint,
required String model,
String? explicitApiKey,
}) async {
if (provider == AiProviderType.mock) {
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
}
const probe =
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
final key = await _secureStorage.read(key: _keyName);
final key = await resolveApiKey(explicitApiKey);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
return const AiConnectionResult(ok: false, message: '请先填写或保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
@@ -133,14 +185,16 @@ class AiService {
message: '请填写 Base URL 和模型名称。',
);
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return const AiConnectionResult(
ok: false,
message: '请使用有效的 HTTP 或 HTTPS 地址。',
);
}
try {
final response = await http
@@ -163,7 +217,8 @@ class AiService {
},
],
'generationConfig': {
'maxOutputTokens': 80,
'temperature': 0,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
@@ -172,35 +227,51 @@ class AiService {
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
'temperature': 0,
'max_tokens': 60,
},
),
)
.timeout(const Duration(seconds: 30));
.timeout(const Duration(seconds: 15));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
return const AiConnectionResult(
ok: true,
message: '连接成功,AI 对话服务可用!',
);
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式',
ok: true,
message: '连接成功,接口响应正常',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return AiConnectionResult(
ok: false,
message: '鉴权失败 (HTTP ${response.statusCode}),请检查 API Key 是否正确。',
);
}
if (response.statusCode == 404) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key',
message: '服务返回 404,请检查 Base URL(如是否缺少 /v1)或模型名称',
);
}
if (response.statusCode == 429) {
return const AiConnectionResult(
ok: false,
message: '请求受限 (HTTP 429),API 额度不足或达到并发限制。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
message: '服务返回 HTTP ${response.statusCode},请检查地址和模型配置',
);
} catch (_) {
return const AiConnectionResult(
} catch (e) {
return AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
message: '无法连接服务 ($e)。请检查网络、地址或代理连通性。',
);
}
}
@@ -215,20 +286,19 @@ class AiService {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') {
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
@@ -303,122 +373,21 @@ class AiService {
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return null;
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $answer''';
'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(
@@ -441,7 +410,7 @@ Learner answer: $answer''';
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 240,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
@@ -451,7 +420,7 @@ Learner answer: $answer''';
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
'max_tokens': 120,
},
),
)
@@ -459,27 +428,110 @@ Learner answer: $answer''';
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final variant = decodeGeneratedReviewVariant(
content,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
if (variant == null && !repairAttempt) {
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
}
return variant;
} catch (_) {
return null;
}
}
/// Evaluates an open-ended writing response against a bounded schema.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
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".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
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': {
'temperature': 0,
'maxOutputTokens': 150,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 150,
},
),
)
.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;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
@@ -488,21 +540,20 @@ Learner answer: $answer''';
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock ||
!a0CoreItems.containsKey(targetItemId)) {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return null;
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.' : ''}';
@@ -527,8 +578,8 @@ Learner answer: $answer''';
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'temperature': 0.1,
'maxOutputTokens': 650,
'responseMimeType': 'application/json',
},
}
@@ -537,51 +588,58 @@ Learner answer: $answer''';
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
'temperature': 0.1,
'max_tokens': 650,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
.timeout(const Duration(seconds: 45));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final lesson = decodeGeneratedLesson(
content,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
if (lesson == null && !repairAttempt) {
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
}
return lesson;
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
/// Sends the entire generated lesson structure to an independent LLM audit.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
if (provider == AiProviderType.mock) return true;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return false;
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
@@ -589,6 +647,7 @@ Learner answer: $answer''';
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
@@ -678,7 +737,40 @@ Learner answer: $answer''';
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
choice?['text'] as String?;
if (choiceContent != null && choiceContent.isNotEmpty) {
return choiceContent;
}
if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
return data['output_text'] as String;
}
final outputList = data['output'] as List?;
if (outputList != null && outputList.isNotEmpty) {
for (final item in outputList) {
if (item is Map) {
if (item['content'] is List) {
for (final sub in item['content'] as List) {
if (sub is Map && sub['text'] is String) {
return sub['text'] as String;
}
}
} else if (item['text'] is String) {
return item['text'] as String;
}
}
}
}
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
return data['response'] as String;
}
if (data['text'] is String && (data['text'] as String).isNotEmpty) {
return data['text'] as String;
}
if (data['content'] is String && (data['content'] as String).isNotEmpty) {
return data['content'] as String;
}
return null;
} catch (_) {
return null;
}
+44
View File
@@ -9,6 +9,8 @@ import 'a0_core.dart';
import 'generated_content.dart';
import 'local_store.dart';
import 'seed_courses.dart';
import 'ai_config.dart';
import 'ai_service.dart';
class AppState extends ChangeNotifier {
static const _storageKey = 'learning_state_v1';
@@ -262,6 +264,22 @@ class AppState extends ChangeNotifier {
Future<void> load() async {
try {
final config = await AiConfigFile.loadFromAsset();
if (config != null) {
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
AiService.instance.setFallbackApiKey(config.apiKey);
}
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
aiEndpoint = config.endpoint;
}
if (aiModel.isEmpty && config.model.isNotEmpty) {
aiModel = config.model;
}
if (aiProvider == AiProviderType.mock &&
config.provider != AiProviderType.mock) {
aiProvider = config.provider;
}
}
String? raw;
if (!_usesLegacyTestStore) {
raw = await LocalSnapshotStore.instance.read();
@@ -275,6 +293,19 @@ class AppState extends ChangeNotifier {
}
}
if (raw != null) _restore(jsonDecode(raw) as Map<String, dynamic>);
if (config != null) {
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
aiEndpoint = config.endpoint;
}
if (aiModel.isEmpty && config.model.isNotEmpty) {
aiModel = config.model;
}
if (aiProvider == AiProviderType.mock &&
config.provider != AiProviderType.mock &&
raw == null) {
aiProvider = config.provider;
}
}
} catch (_) {
// A corrupt local cache must never prevent access to offline lessons.
} finally {
@@ -283,6 +314,19 @@ class AppState extends ChangeNotifier {
}
}
Future<bool> reloadAiConfigFromAsset() async {
final config = await AiConfigFile.loadFromAsset();
if (config == null) return false;
aiProvider = config.provider;
aiEndpoint = config.endpoint;
aiModel = config.model;
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
AiService.instance.setFallbackApiKey(config.apiKey);
}
notifyListeners();
return true;
}
void _restore(Map<String, dynamic> data) {
onboardingComplete =
data['onboardingComplete'] as bool? ?? onboardingComplete;
+44 -6
View File
@@ -17,15 +17,53 @@ class VoiceService {
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
bool _speechReady = false;
bool _ttsInitialized = false;
Future<void> speak(String text, {bool slow = false}) async {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
Future<void> _initTts() async {
if (_ttsInitialized) return;
try {
if (Platform.isIOS) {
await _tts.setIosAudioCategory(
IosTextToSpeechAudioCategory.playback,
[
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
],
);
}
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_ttsInitialized = true;
} catch (_) {}
}
Future<void> stopSpeaking() => _tts.stop();
Future<void> speak(String text, {bool slow = false}) async {
try {
await _initTts();
await _tts.stop();
try {
final isAvailable = await _tts.isLanguageAvailable('en-US');
if (isAvailable == true) {
await _tts.setLanguage('en-US');
} else {
await _tts.setLanguage('en');
}
} catch (_) {
try {
await _tts.setLanguage('en-US');
} catch (_) {}
}
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
} catch (_) {}
}
Future<void> stopSpeaking() async {
try {
await _tts.stop();
} catch (_) {}
}
Future<bool> startRecording() async {
if (!await _recorder.hasPermission()) return false;
@@ -46,7 +46,14 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
final canResume = draft != null && draft.packId == widget.pack.id;
final pack = widget.pack;
return AppPage(
appBar: AppBar(title: const Text('评估准备')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onBack,
),
title: const Text('评估准备'),
),
child: SpacedColumn(
children: [
Eyebrow('A0 阶段评估 · ${pack.id}'),
@@ -152,7 +159,9 @@ class _AssessmentPageState extends State<AssessmentPage> {
}
Future<void> _play() async {
await VoiceService.instance.speak(task.audio!);
try {
await VoiceService.instance.speak(task.audio!);
} catch (_) {}
if (mounted) setState(() => audioPlayed = true);
}
@@ -285,6 +294,14 @@ class _AssessmentPageState extends State<AssessmentPage> {
final record = completedRecord;
if (record != null) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: Text(record.passed ? "阶段评估通过" : "阶段评估结果"),
),
child: SpacedColumn(
children: [
const Eyebrow('评估结果已保存'),
@@ -325,6 +342,11 @@ class _AssessmentPageState extends State<AssessmentPage> {
}
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出评估",
onPressed: widget.onFinished,
),
title: Text(
'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}',
),
@@ -344,7 +366,12 @@ class _AssessmentPageState extends State<AssessmentPage> {
const Text('请根据听到的内容选择答案。'),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: audioPlayed ? () => _submit(i) : null,
onTap: () {
if (!audioPlayed) {
_play();
}
_submit(i);
},
child: Text(task.choices[i]),
),
] else if (task.skill == AssessmentSkill.reading) ...[
@@ -10,11 +10,26 @@ import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart});
const DialogueScenePage({
super.key,
required this.onStart,
this.onBack,
});
final VoidCallback onStart;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
@@ -157,10 +172,17 @@ class _DialoguePageState extends State<DialoguePage> {
} else {
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
}
});
}
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
@@ -217,20 +239,21 @@ class _DialoguePageState extends State<DialoguePage> {
.toList(),
);
if (!mounted) return;
final replyText = aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!');
setState(() {
turns.add(
DialogueTurn(
text:
aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!'),
text: replyText,
isLearner: false,
),
);
waitingForReply = false;
});
_saveDraft();
VoiceService.instance.speak(replyText);
}
void _saveDraft() {
@@ -417,6 +440,11 @@ class _DialoguePageState extends State<DialoguePage> {
final finished = stage == script.prompts.length;
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
),
@@ -446,7 +474,37 @@ class _DialoguePageState extends State<DialoguePage> {
: AppColors.softGreen,
borderRadius: BorderRadius.circular(15),
),
child: LexiconText(turn.text, state: widget.state),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LexiconText(turn.text, state: widget.state),
if (!turn.isLearner) ...[
const SizedBox(height: 6),
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,
),
),
],
),
),
],
],
),
),
);
},
@@ -612,13 +670,24 @@ class DialogueSummaryPage extends StatelessWidget {
required this.onHome,
required this.onLesson,
required this.onRetry,
this.onBack,
});
final DialogueSummaryData summary;
final VoidCallback onHome;
final VoidCallback onLesson;
final VoidCallback onRetry;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack ?? onHome,
),
title: const Text("对话完成"),
),
child: SpacedColumn(
children: [
const Eyebrow('对话完成'),
@@ -101,7 +101,7 @@ class _LessonFlowState extends State<LessonFlow> {
state: widget.state,
initialText: activity.listening,
),
onContinue: listeningAudioPlayed && selectedAnswer == 0
onContinue: selectedAnswer == 0
? widget.state.completeListening
: null,
);
@@ -192,6 +192,7 @@ class _LessonFlowState extends State<LessonFlow> {
return _LessonScope(
title:
'${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length}',
onExit: widget.onFinish,
child: content,
);
}
@@ -205,7 +206,21 @@ class _LessonScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: Text(_LessonScope.of(context))),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出课程",
onPressed: () {
final onExit = _LessonScope.exitOf(context);
if (onExit != null) {
onExit();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: Text(_LessonScope.of(context)),
),
child: SpacedColumn(
spacing: 16,
children: [
@@ -231,16 +246,25 @@ class _LessonScaffold extends StatelessWidget {
}
class _LessonScope extends InheritedWidget {
const _LessonScope({required this.title, required super.child});
const _LessonScope({
required this.title,
this.onExit,
required super.child,
});
final String title;
final VoidCallback? onExit;
static String of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ??
'A0 课程练习';
static VoidCallback? exitOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
@override
bool updateShouldNotify(_LessonScope oldWidget) => title != oldWidget.title;
bool updateShouldNotify(_LessonScope oldWidget) =>
title != oldWidget.title || onExit != oldWidget.onExit;
}
class _PreviewStep extends StatelessWidget {
@@ -357,7 +381,12 @@ class _ListeningStep extends StatelessWidget {
for (var index = 0; index < answers.length; index++)
SectionCard(
tint: selectedAnswer == index ? AppColors.softGreen : null,
onTap: audioPlayed ? () => onSelected(index) : null,
onTap: () {
onSelected(index);
if (!audioPlayed) {
onPlayed();
}
},
child: Row(
children: [
Icon(
@@ -374,7 +403,9 @@ class _ListeningStep extends StatelessWidget {
),
),
PrimaryButton(
label: audioPlayed ? '检查并继续' : '先播放音频',
label: selectedAnswer >= 0
? '检查并继续'
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
onPressed: onContinue,
),
if (selectedAnswer >= 0 && selectedAnswer != 0)
@@ -1126,16 +1157,38 @@ class _AudioRow extends StatelessWidget {
children: [
IconButton.filled(
onPressed: () async {
await VoiceService.instance.speak(speech ?? label);
onPlayed?.call();
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
icon: const Icon(Icons.play_arrow),
),
const SizedBox(width: 10),
Expanded(child: Text(label)),
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(label),
),
),
),
TextButton(
onPressed: () =>
VoiceService.instance.speak(speech ?? label, slow: true),
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
@@ -70,10 +70,16 @@ class _WelcomePageState extends State<WelcomePage> {
}
class PlacementPage extends StatefulWidget {
const PlacementPage({super.key, required this.state, required this.onStart});
const PlacementPage({
super.key,
required this.state,
required this.onStart,
this.onBack,
});
final AppState state;
final VoidCallback onStart;
final VoidCallback? onBack;
@override
State<PlacementPage> createState() => _PlacementPageState();
@@ -96,6 +102,16 @@ class _PlacementPageState extends State<PlacementPage> {
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return AppPage(
appBar: widget.onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onBack,
),
title: const Text("基础定位"),
)
: null,
child: SpacedColumn(
spacing: 14,
children: [
@@ -199,8 +199,10 @@ class _AbilityRow extends StatelessWidget {
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.state});
const SettingsPage({super.key, required this.state, this.onBack});
final AppState state;
final VoidCallback? onBack;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
@@ -209,6 +211,8 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
bool _testingConnection = false;
@override
void initState() {
super.initState();
@@ -226,7 +230,20 @@ class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: const Text('学习设置')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: const Text('学习设置'),
),
child: SpacedColumn(
spacing: 4,
children: [
@@ -350,17 +367,49 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
SecondaryButton(
label: '测试连接',
label: _testingConnection ? '正在测试连接...' : '测试连接',
onPressed: _testingConnection
? null
: () async {
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
explicitApiKey: apiKey.text,
);
if (!mounted) return;
setState(() => _testingConnection = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
result.ok ? AppColors.green : Colors.redAccent,
duration: const Duration(seconds: 4),
),
);
},
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(result.message)));
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
},
),
const Divider(height: 28),
@@ -482,6 +482,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson == null) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("AI 四技能补练"),
),
child: SpacedColumn(
children: [
const Eyebrow('AI 四技能补练'),
@@ -494,6 +502,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final done = index >= lesson.tasks.length;
if (done) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("补练完成"),
),
child: SpacedColumn(
children: [
const Eyebrow('补练已完成'),
@@ -510,7 +526,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final task = lesson.tasks[index];
final showStimulus = task.skill != 'listening';
return AppPage(
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: Text('AI 补练 · ${index + 1}/4'),
),
child: SpacedColumn(
children: [
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
@@ -1,17 +1,17 @@
import 'package:flutter/material.dart';
import "package:flutter/material.dart";
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/seed_courses.dart';
import '../../core/assessment_bank.dart';
import '../../widgets/app_widgets.dart';
import '../dialogue/dialogue_flow.dart';
import '../assessment/assessment_page.dart';
import '../home/home_page.dart';
import '../lesson/lesson_flow.dart';
import '../progress/progress_pages.dart';
import '../review/review_page.dart';
import "../../core/app_state.dart";
import "../../core/app_theme.dart";
import "../../core/models.dart";
import "../../core/seed_courses.dart";
import "../../core/assessment_bank.dart";
import "../../widgets/app_widgets.dart";
import "../dialogue/dialogue_flow.dart";
import "../assessment/assessment_page.dart";
import "../home/home_page.dart";
import "../lesson/lesson_flow.dart";
import "../progress/progress_pages.dart";
import "../review/review_page.dart";
class LearningShell extends StatefulWidget {
const LearningShell({super.key, required this.state});
@@ -24,6 +24,7 @@ class LearningShell extends StatefulWidget {
class _LearningShellState extends State<LearningShell> {
AppTab tab = AppTab.home;
var route = _ShellRoute.tab;
_ShellRoute? previousRoute;
bool dialogueInLesson = false;
AssessmentPack? assessmentPack;
DialogueSummaryData? dialogueSummary;
@@ -31,26 +32,86 @@ class _LearningShellState extends State<LearningShell> {
void showTab(AppTab value) => setState(() {
tab = value;
route = _ShellRoute.tab;
previousRoute = null;
});
void showLesson() => setState(() {
previousRoute = route;
route = _ShellRoute.lesson;
});
void showDialogueScene() => setState(() {
previousRoute = route;
route = _ShellRoute.scene;
});
void showLesson() => setState(() => route = _ShellRoute.lesson);
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
void showDialogue({bool inLesson = false}) => setState(() {
previousRoute = route;
dialogueInLesson = inLesson;
route = _ShellRoute.dialogue;
});
void showSummary(DialogueSummaryData summary) => setState(() {
previousRoute = route;
dialogueSummary = summary;
route = _ShellRoute.summary;
});
void showSettings() => setState(() => route = _ShellRoute.settings);
void showSettings() => setState(() {
previousRoute = route;
route = _ShellRoute.settings;
});
void showAssessment(AssessmentPack pack) => setState(() {
previousRoute = route;
assessmentPack = pack;
route = _ShellRoute.assessmentPreparation;
});
void startAssessment() => setState(() => route = _ShellRoute.assessment);
void showAdaptiveLesson() =>
setState(() => route = _ShellRoute.adaptiveLesson);
void startAssessment() => setState(() {
previousRoute = route;
route = _ShellRoute.assessment;
});
void showAdaptiveLesson() => setState(() {
previousRoute = route;
route = _ShellRoute.adaptiveLesson;
});
void handleBack() {
switch (route) {
case _ShellRoute.lesson:
showTab(tab);
case _ShellRoute.scene:
showTab(tab);
case _ShellRoute.dialogue:
if (dialogueInLesson) {
showLesson();
} else if (previousRoute == _ShellRoute.scene) {
showDialogueScene();
} else {
showTab(tab == AppTab.dialogue ? AppTab.dialogue : tab);
}
case _ShellRoute.summary:
showTab(tab);
case _ShellRoute.settings:
showTab(AppTab.progress);
case _ShellRoute.adaptiveLesson:
showTab(AppTab.review);
case _ShellRoute.assessmentPreparation:
showTab(AppTab.progress);
case _ShellRoute.assessment:
if (assessmentPack != null) {
showAssessment(assessmentPack!);
} else {
showTab(AppTab.progress);
}
case _ShellRoute.tab:
if (tab != AppTab.home) {
showTab(AppTab.home);
}
}
}
@override
Widget build(BuildContext context) {
@@ -60,19 +121,26 @@ class _LearningShellState extends State<LearningShell> {
body = LessonFlow(
state: widget.state,
onOpenDialogue: () => showDialogue(inLesson: true),
onFinish: () => showTab(AppTab.home),
onFinish: () => showTab(tab),
);
case _ShellRoute.scene:
body = DialogueScenePage(onStart: showDialogue);
body = DialogueScenePage(
onStart: showDialogue,
onBack: () => showTab(tab),
);
case _ShellRoute.dialogue:
body = DialoguePage(
state: widget.state,
isLessonDialogue: dialogueInLesson,
onFinished: dialogueInLesson
? (_) => showLesson()
: (summary) {
if (summary != null) showSummary(summary);
},
onFinished: (summary) {
if (dialogueInLesson) {
showLesson();
} else if (summary != null) {
showSummary(summary);
} else {
handleBack();
}
},
);
case _ShellRoute.summary:
body = DialogueSummaryPage(
@@ -80,9 +148,13 @@ class _LearningShellState extends State<LearningShell> {
onHome: () => showTab(AppTab.home),
onLesson: showLesson,
onRetry: showDialogue,
onBack: () => showTab(tab),
);
case _ShellRoute.settings:
body = SettingsPage(state: widget.state);
body = SettingsPage(
state: widget.state,
onBack: () => showTab(AppTab.progress),
);
case _ShellRoute.adaptiveLesson:
body = AdaptiveLessonPage(
state: widget.state,
@@ -106,45 +178,53 @@ class _LearningShellState extends State<LearningShell> {
body = _tabContent();
}
return AnimatedBuilder(
animation: widget.state,
builder: (context, _) => Scaffold(
body: body,
bottomNavigationBar: route == _ShellRoute.tab
? NavigationBar(
selectedIndex: tab.index,
height: 70,
indicatorColor: AppColors.softGreen,
onDestinationSelected: (index) => showTab(AppTab.values[index]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: '学习',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: '对话',
),
NavigationDestination(
icon: Icon(Icons.refresh_outlined),
selectedIcon: Icon(Icons.refresh),
label: '复习',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
)
: null,
return PopScope(
canPop: route == _ShellRoute.tab && tab == AppTab.home,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
handleBack();
}
},
child: AnimatedBuilder(
animation: widget.state,
builder: (context, _) => Scaffold(
body: body,
bottomNavigationBar: route == _ShellRoute.tab
? NavigationBar(
selectedIndex: tab.index,
height: 70,
indicatorColor: AppColors.softGreen,
onDestinationSelected: (index) => showTab(AppTab.values[index]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: "首页",
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: "学习",
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: "对话",
),
NavigationDestination(
icon: Icon(Icons.refresh_outlined),
selectedIcon: Icon(Icons.refresh),
label: "复习",
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: "我的",
),
],
)
: null,
),
),
);
}
@@ -154,10 +234,14 @@ class _LearningShellState extends State<LearningShell> {
case AppTab.home:
return HomePage(
state: widget.state,
onStartPrimaryTask: widget.state.reviewIsPrimary
? () => showTab(AppTab.review)
: showLesson,
onOpenDialogue: showDialogue,
onStartPrimaryTask: () {
if (widget.state.reviewIsPrimary) {
showTab(AppTab.review);
} else {
showLesson();
}
},
onOpenDialogue: showDialogueScene,
onResumeLessonDialogue: () => showDialogue(inLesson: true),
);
case AppTab.learn:
@@ -220,19 +304,19 @@ class _LearningMap extends StatelessWidget {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('学习地图 · 按掌握状态推进'),
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
const Text('每节课都围绕一个能完成的小任务。'),
const Eyebrow("学习地图 · 按掌握状态推进"),
Text("从认识到开口", style: Theme.of(context).textTheme.headlineMedium),
const Text("每节课都围绕一个能完成的小任务。"),
if (state.reviewBacklog)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text(
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
"复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。",
style: const TextStyle(color: AppColors.warmInk),
),
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
SecondaryButton(label: "先去复习", onPressed: onOpenReview),
],
),
),
@@ -267,13 +351,13 @@ class _LearningMap extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${lesson.number} 课 · ${lesson.title}',
"${lesson.number} 课 · ${lesson.title}",
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(
lesson.segments.length > 1
? '${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}'
? "${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}"
: lesson.outcome,
style: Theme.of(context).textTheme.bodyMedium,
),
@@ -289,12 +373,12 @@ class _LearningMap extends StatelessWidget {
child: SpacedColumn(
children: [
const Text(
'A0 巩固变式',
"A0 巩固变式",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
const Text("换一个人物、地点或情境,继续巩固尚未稳定的核心表达。"),
PrimaryButton(
label: '安排一题巩固练习',
label: "安排一题巩固练习",
onPressed: onStartReinforcement,
),
],
+12 -3
View File
@@ -55,9 +55,18 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
onContinue: () => setState(() => onboardingStep = 1),
);
}
return PlacementPage(
state: appState,
onStart: () => appState.finishOnboarding(),
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
setState(() => onboardingStep = 0);
}
},
child: PlacementPage(
state: appState,
onBack: () => setState(() => onboardingStep = 0),
onStart: () => appState.finishOnboarding(),
),
);
},
),
+3 -68
View File
@@ -1,38 +1,16 @@
name: kouyu_english
description: "开口英语 M1:面向 A0 成人的本地优先英语学习原型。"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: 'none'
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.11.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_secure_storage: ^10.0.0
flutter_tts: ^4.2.3
@@ -48,52 +26,9 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
assets:
- assets/config/
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_config.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/models.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AiService resolveEndpointUri (/v1/chat/completions)', () {
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.openAi,
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
});
test('resolves OpenAI endpoint without /v1 to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.openAi,
endpoint: 'https://api.openai.com',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
});
test('resolves custom compatible endpoint to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
});
test('preserves endpoint already ending in /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/chat/completions',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
});
test('converts /responses endpoint to /chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
});
test('resolves Gemini endpoint to :generateContent', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.gemini,
endpoint: 'https://generativelanguage.googleapis.com/v1beta',
model: 'gemini-2.5-flash',
);
expect(
uri.toString(),
equals('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'),
);
});
});
group('AiConfigFile', () {
test('parses standard JSON configuration correctly', () {
const rawJson = '''{
"provider": "compatible",
"endpoint": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
"apiKey": "sk-test-key-12345",
"description": "Default AI configuration"
}''';
final config = AiConfigFile.parse(rawJson);
expect(config.provider, equals(AiProviderType.compatible));
expect(config.endpoint, equals('https://api.openai.com/v1'));
expect(config.model, equals('gpt-4o-mini'));
expect(config.apiKey, equals('sk-test-key-12345'));
expect(config.description, equals('Default AI configuration'));
});
test('handles case-insensitive provider mapping and fallbacks', () {
const openAiJson = '{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
expect(AiConfigFile.parse(openAiJson).provider, equals(AiProviderType.openAi));
const geminiJson = '{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
expect(AiConfigFile.parse(geminiJson).provider, equals(AiProviderType.gemini));
const mockJson = '{"provider": "mock", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(mockJson).provider, equals(AiProviderType.mock));
const unknownJson = '{"provider": "unknown_provider", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(unknownJson).provider, equals(AiProviderType.compatible));
});
test('serializes to JSON correctly', () {
const config = AiConfigFile(
provider: AiProviderType.compatible,
endpoint: 'http://localhost:8000/v1',
model: 'llama3',
apiKey: 'sk-local',
description: 'Local proxy',
);
final json = config.toJson();
expect(json['provider'], equals('compatible'));
expect(json['endpoint'], equals('http://localhost:8000/v1'));
expect(json['model'], equals('llama3'));
expect(json['apiKey'], equals('sk-local'));
expect(json['description'], equals('Local proxy'));
});
});
group('AiService API Key Resolution', () {
test('uses explicit key over fallback key', () async {
AiService.instance.setFallbackApiKey('fallback-key');
final key = await AiService.instance.resolveApiKey('explicit-override');
expect(key, equals('explicit-override'));
});
test('falls back to fallbackApiKey when no explicit or secure key is set', () async {
AiService.instance.setFallbackApiKey('config-file-key');
final key = await AiService.instance.resolveApiKey();
expect(key, equals('config-file-key'));
});
});
}
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
import 'package:kouyu_english/features/review/review_page.dart';
import 'package:kouyu_english/features/shell/learning_shell.dart';
import 'package:kouyu_english/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
tester,
) async {
await tester.pumpWidget(const KouyuEnglishApp());
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
await tester.tap(find.text('继续'));
await tester.pumpAndSettle();
expect(find.text('从哪里开始?'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
});
testWidgets('SettingsPage back button returns to ProgressPage', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
// Navigate to "我的" (progress tab)
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
final settingsButton = find.text('调整学习与 AI 设置');
await tester.ensureVisible(settingsButton);
await tester.pumpAndSettle();
expect(settingsButton, findsOneWidget);
await tester.tap(settingsButton);
await tester.pumpAndSettle();
// In SettingsPage
expect(find.text('学习设置'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('调整学习与 AI 设置'), findsOneWidget);
});
testWidgets('LessonFlow back button returns to tab', (tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
expect(find.text('开始今天的学习'), findsOneWidget);
await tester.tap(find.text('开始今天的学习'));
await tester.pumpAndSettle();
// In LessonFlow
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('开始今天的学习'), findsOneWidget);
});
testWidgets('DialogueScenePage from Home has back button and returns to Home', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
await tester.tap(find.text('开始情境对话'));
await tester.pumpAndSettle();
// In DialogueScenePage as secondary route
expect(find.text('AI 情境对话'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
});
testWidgets('DialogueSummaryPage has back button', (tester) async {
var backCalled = false;
await tester.pumpWidget(
MaterialApp(
home: DialogueSummaryPage(
summary: const DialogueSummaryData(
completedTasks: ['介绍姓名'],
personalSentence: 'My name is Shen.',
usedHelp: false,
),
onHome: () {},
onLesson: () {},
onRetry: () {},
onBack: () => backCalled = true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('对话完成'), findsNWidgets(2)); // AppBar title and Eyebrow
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(backCalled, isTrue);
});
testWidgets('AdaptiveLessonPage has back button in all states', (tester) async {
final state = AppState()..finishOnboarding();
// 1. Empty state
var finished = false;
await tester.pumpWidget(
MaterialApp(
home: AdaptiveLessonPage(
state: state,
onFinished: () => finished = true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('AI 四技能补练'), findsNWidgets(2));
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
expect(finished, isTrue);
// 2. Active lesson state
state.cacheApprovedAdaptiveLesson(
const GeneratedLesson(
lessonId: 'adapt-1',
revision: 1,
stageVersion: 'A0',
abilityIds: ['greeting'],
prerequisiteIds: [],
targetItemIds: ['name'],
receptiveChunks: ['My name is Mia.'],
previewItemIds: ['name'],
estimatedMinutes: 5,
tasks: [
GeneratedLessonTask(
taskId: 't1',
skill: 'listening',
type: 'listen',
prompt: '听并写出名字',
stimulus: 'My name is Mia.',
answer: 'Mia',
targetItemIds: ['name'],
),
],
),
auditor: 'test',
);
finished = false;
await tester.pumpWidget(
MaterialApp(
home: AdaptiveLessonPage(
state: state,
onFinished: () => finished = true,
),
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
expect(finished, isTrue);
});
testWidgets('AssessmentPreparationPage and AssessmentPage have back buttons', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
// Navigate to "我的"
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
// Open assessment pack A0-E1
final packAButton = find.text('开始 A0-E1');
await tester.ensureVisible(packAButton);
await tester.pumpAndSettle();
expect(packAButton, findsOneWidget);
await tester.tap(packAButton);
await tester.pumpAndSettle();
// In AssessmentPreparationPage
expect(find.text('评估准备'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Start assessment
await tester.tap(find.text('开始评估'));
await tester.pumpAndSettle();
// In AssessmentPage
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button in AssessmentPage
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
// Returns to Progress tab
expect(find.text('A0 四技能评估'), findsOneWidget);
});
}