feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定
- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进 - 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑 - 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示 - 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿 - 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
|
||||
@@ -60,15 +60,11 @@
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'models.dart';
|
||||
import 'courses/courses.dart';
|
||||
import 'generated_content.dart';
|
||||
|
||||
class AiConnectionResult {
|
||||
@@ -796,7 +797,8 @@ class AiService {
|
||||
);
|
||||
final turnNote =
|
||||
'[Turn] Your next line must do this: $aiGoal '
|
||||
'After your line the learner has to: $learnerTask.';
|
||||
'After your line the learner has to: $learnerTask. '
|
||||
'Do not repeat any questions or greetings already asked or answered.';
|
||||
// Earlier AI lines are stored as plain English. Sent that way they teach
|
||||
// the model to answer in plain text (or, in JSON mode, with blanks), so
|
||||
// they are replayed in the JSON shape the system prompt asks for.
|
||||
@@ -854,6 +856,9 @@ class AiService {
|
||||
'Each user message ends with a [Turn] note saying what your next line '
|
||||
'must do and what the learner has to say after it. '
|
||||
'Do not say the learner sentence for them, and do not ask for anything else. '
|
||||
'Strictly do not repeat any question, greeting, or inquiry that has already been asked or answered in earlier turns. '
|
||||
'Check the conversation history carefully: never ask for information the learner has already given (e.g. name, location, feelings, etc.). '
|
||||
'If the turn goal asks about something already provided in history, acknowledge it naturally and advance the conversation forward instead of re-asking. '
|
||||
'Reply with one short sentence or question, at most 20 English words, in English only. '
|
||||
'Do not explain grammar. '
|
||||
'Return JSON only: {"reply": "your English line", '
|
||||
@@ -994,9 +999,11 @@ Learner wrote: $answer''';
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
||||
final level = itemLevel(targetItemId);
|
||||
final lessonId = 'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
|
||||
final stageVersion = '$level-1.0';
|
||||
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, the lessonId given below, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [target item id], 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 [target item id]. 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 the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
|
||||
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion $stageVersion, source aiGenerated, status validated, targetItemIds [target item id], 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 [target item id]. 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 $level English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
|
||||
'lessonId: $lessonId\n'
|
||||
'Target item id: $targetItemId\n'
|
||||
'Target expression: $targetLabel'
|
||||
|
||||
@@ -263,7 +263,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
}
|
||||
|
||||
void finishLesson() {
|
||||
if (!lessonCanComplete) return;
|
||||
if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) {
|
||||
return;
|
||||
}
|
||||
completedLessonIds.add(activeLessonId);
|
||||
completedLessons = completedLessonIds.length;
|
||||
final next = _nextIncompleteLessonId();
|
||||
@@ -273,6 +275,11 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
_syncInBackground();
|
||||
}
|
||||
|
||||
bool isLessonSegmentsAllComplete(String lessonId) {
|
||||
final lesson = lessonById(lessonId);
|
||||
return lesson.segments.every((seg) => completedSegmentIds.contains(seg.id));
|
||||
}
|
||||
|
||||
/// Merges lesson progress pulled from the sync server.
|
||||
///
|
||||
/// Completed lessons/segments are unioned, and the learner's position only
|
||||
@@ -343,6 +350,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
|
||||
/// The first unfinished lesson from the placement start onward, falling back
|
||||
/// to any earlier unfinished lesson. Null once every lesson is finished.
|
||||
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
|
||||
|
||||
String? _nextIncompleteLessonId() {
|
||||
final lessons = allLessons;
|
||||
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
||||
|
||||
@@ -18,6 +18,67 @@ final Map<String, LessonDialogue> segmentDialogues = <String, LessonDialogue>{};
|
||||
final Map<String, String> segmentGrammarNotes = <String, String>{};
|
||||
final List<DialogueScene> practiceScenes = <DialogueScene>[];
|
||||
|
||||
/// All distinct English words introduced in each CEFR level, populated from
|
||||
/// the course JSON content packs.
|
||||
final Map<String, Set<String>> levelVocabularyWords = <String, Set<String>>{};
|
||||
|
||||
const List<String> cefrLevels = ['A0', 'A1', 'A2', 'B1'];
|
||||
|
||||
/// Registers words for [level] into the vocabulary index.
|
||||
void registerLevelWords(String level, Iterable<String> words) {
|
||||
final set = levelVocabularyWords.putIfAbsent(
|
||||
level.toUpperCase(),
|
||||
() => <String>{},
|
||||
);
|
||||
for (final w in words) {
|
||||
final clean = w.toLowerCase().replaceAll("'", '').trim();
|
||||
if (clean.isNotEmpty) set.add(clean);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed demo names and places accepted across all levels as standard practice slots.
|
||||
const Set<String> standardDemoEntities = {
|
||||
'alex',
|
||||
'mia',
|
||||
'shen',
|
||||
'bo',
|
||||
'ava',
|
||||
'tom',
|
||||
'sam',
|
||||
'beijing',
|
||||
'shanghai',
|
||||
'hong',
|
||||
'kong',
|
||||
'china',
|
||||
'london',
|
||||
};
|
||||
|
||||
/// Returns all English words taught in the course up to [level] (inclusive).
|
||||
Set<String> taughtCourseWordsUpToLevel(String level) {
|
||||
final targetIdx = cefrLevels.indexOf(level.toUpperCase());
|
||||
final maxIdx = targetIdx < 0 ? 0 : targetIdx;
|
||||
final result = Set<String>.from(standardDemoEntities);
|
||||
for (var i = 0; i <= maxIdx && i < cefrLevels.length; i++) {
|
||||
final words = levelVocabularyWords[cefrLevels[i]];
|
||||
if (words != null) result.addAll(words);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns all English words taught in the course up to [targetItemId]'s level.
|
||||
Set<String> taughtCourseWordsUpToItem(String targetItemId) {
|
||||
return taughtCourseWordsUpToLevel(itemLevel(targetItemId));
|
||||
}
|
||||
|
||||
/// All distinct English words across all loaded course packs.
|
||||
Set<String> allLoadedCourseWords() {
|
||||
final result = <String>{};
|
||||
for (final words in levelVocabularyWords.values) {
|
||||
result.addAll(words);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Empties every registry, for `CourseRepository.resetForTest`.
|
||||
void clearCourseCatalog() {
|
||||
courseLessons.clear();
|
||||
@@ -28,6 +89,7 @@ void clearCourseCatalog() {
|
||||
segmentGrammarNotes.clear();
|
||||
practiceScenes.clear();
|
||||
assessmentPacks.clear();
|
||||
levelVocabularyWords.clear();
|
||||
}
|
||||
|
||||
/// Every lesson the app can teach, in learning order: A0 first, then the
|
||||
|
||||
@@ -120,10 +120,57 @@ class CourseRepository {
|
||||
_register(pack);
|
||||
}
|
||||
|
||||
void _indexPackVocabulary(CoursePack pack) {
|
||||
final words = <String>{};
|
||||
void addText(String? text) {
|
||||
if (text == null || text.isEmpty) return;
|
||||
final matches = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?").allMatches(text);
|
||||
for (final m in matches) {
|
||||
final word = m.group(0)!.replaceAll("'", '').toLowerCase();
|
||||
if (word.isNotEmpty) words.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
for (final item in pack.coreItems) {
|
||||
addText(item.en);
|
||||
addText(item.exampleEn);
|
||||
for (final group in item.match) {
|
||||
for (final term in group) {
|
||||
addText(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final rw in pack.receptiveWords) {
|
||||
addText(rw.en);
|
||||
}
|
||||
for (final seg in pack.segments) {
|
||||
addText(seg.listeningText);
|
||||
addText(seg.speakingText);
|
||||
addText(seg.readingText);
|
||||
addText(seg.writingExample);
|
||||
if (seg.vocabulary != null) {
|
||||
for (final v in seg.vocabulary!) {
|
||||
addText(v.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final scene in pack.scenes) {
|
||||
for (final turn in scene.turns) {
|
||||
addText(turn.ai);
|
||||
addText(turn.model);
|
||||
}
|
||||
}
|
||||
for (final mat in pack.materials) {
|
||||
addText(mat.text);
|
||||
}
|
||||
registerLevelWords(pack.level, words);
|
||||
}
|
||||
|
||||
/// Adapts one pack into the shared runtime registries. Explicit rules and
|
||||
/// texts in the pack win; anything left out is derived from the core items
|
||||
/// the part teaches.
|
||||
void _register(CoursePack pack) {
|
||||
_indexPackVocabulary(pack);
|
||||
final byId = {for (final item in pack.coreItems) item.id: item};
|
||||
final scenesById = {for (final scene in pack.scenes) scene.id: scene};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'courses/courses.dart';
|
||||
import 'models.dart';
|
||||
|
||||
@@ -248,11 +250,13 @@ GeneratedLesson? decodeGeneratedLesson(
|
||||
final newItems = _stringList(data['newItemIds'], max: 8);
|
||||
final preview = _stringList(data['previewItemIds'], max: 8);
|
||||
final rawTasks = data['tasks'];
|
||||
final expectedLevel = itemLevel(expectedTargetItemId);
|
||||
final expectedStageVersion = '$expectedLevel-1.0';
|
||||
if (lessonId is! String ||
|
||||
!RegExp(r'^ai-a0-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
|
||||
!RegExp(r'^ai-[a-z0-9]{2}-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
|
||||
revision is! int ||
|
||||
revision < 1 ||
|
||||
stageVersion != 'A0-1.0' ||
|
||||
stageVersion != expectedStageVersion ||
|
||||
estimatedMinutes is! int ||
|
||||
estimatedMinutes < 8 ||
|
||||
estimatedMinutes > 15 ||
|
||||
@@ -314,6 +318,7 @@ GeneratedLesson? decodeGeneratedLesson(
|
||||
rawTask['answerSpec'],
|
||||
fallbackAnswer: answer is String ? answer : '',
|
||||
required: data['schemaVersion'] == 'lesson-2',
|
||||
targetItemId: expectedTargetItemId,
|
||||
);
|
||||
if (taskId is! String ||
|
||||
taskId.length > 80 ||
|
||||
@@ -330,8 +335,11 @@ GeneratedLesson? decodeGeneratedLesson(
|
||||
answer is! String ||
|
||||
answer.trim().isEmpty ||
|
||||
answer.length > 160 ||
|
||||
!_usesOnlyA0GeneratedWords(stimulus) ||
|
||||
!_usesOnlyA0GeneratedWords(answer) ||
|
||||
!usesAllowedCourseWords(
|
||||
stimulus,
|
||||
targetItemId: expectedTargetItemId,
|
||||
) ||
|
||||
!usesAllowedCourseWords(answer, targetItemId: expectedTargetItemId) ||
|
||||
answerSpec == null ||
|
||||
taskTargets == null ||
|
||||
taskTargets.length != 1 ||
|
||||
@@ -409,6 +417,7 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
|
||||
Object? value, {
|
||||
required String fallbackAnswer,
|
||||
required bool required,
|
||||
String? targetItemId,
|
||||
}) {
|
||||
if (value == null && !required && fallbackAnswer.trim().isNotEmpty) {
|
||||
return GeneratedAnswerSpec.referenceOnly(fallbackAnswer);
|
||||
@@ -438,7 +447,11 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
|
||||
...forbidden,
|
||||
...groups.expand((group) => group),
|
||||
];
|
||||
if (phrases.any((phrase) => !_usesOnlyA0GeneratedWords(phrase))) return null;
|
||||
if (phrases.any(
|
||||
(phrase) => !usesAllowedCourseWords(phrase, targetItemId: targetItemId),
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
final spec = GeneratedAnswerSpec(
|
||||
requiredAnyPhrases: groups,
|
||||
acceptedAnswers: accepted,
|
||||
@@ -452,7 +465,7 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
|
||||
prompt: '验证',
|
||||
stimulus: fallbackAnswer,
|
||||
answer: fallbackAnswer,
|
||||
targetItemIds: const ['A0-P01'],
|
||||
targetItemIds: [targetItemId ?? 'A0-P01'],
|
||||
answerSpec: spec,
|
||||
),
|
||||
fallbackAnswer,
|
||||
@@ -461,98 +474,29 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
|
||||
: null;
|
||||
}
|
||||
|
||||
/// Dynamic A0 reinforcement must not smuggle in a harder English word via a
|
||||
/// stimulus or answer. Prompts may be Chinese; only learner-facing English is
|
||||
/// constrained here. Names and places are fixed, non-personal demo values.
|
||||
bool _usesOnlyA0GeneratedWords(String value) {
|
||||
/// Validates that learner-facing generated English (stimulus, answer, answerSpec)
|
||||
/// only uses English vocabulary introduced in the course JSON content packs up to
|
||||
/// the target item's level.
|
||||
bool usesAllowedCourseWords(String value, {String? targetItemId}) {
|
||||
final words = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?")
|
||||
.allMatches(value.toLowerCase())
|
||||
.map((match) => match.group(0)!.replaceAll("'", ''));
|
||||
const allowed = {
|
||||
'i',
|
||||
'im',
|
||||
'am',
|
||||
'my',
|
||||
'name',
|
||||
'is',
|
||||
'what',
|
||||
'your',
|
||||
'nice',
|
||||
'to',
|
||||
'meet',
|
||||
'you',
|
||||
'how',
|
||||
'do',
|
||||
'spell',
|
||||
'that',
|
||||
'hello',
|
||||
'hi',
|
||||
'good',
|
||||
'okay',
|
||||
'tired',
|
||||
'thanks',
|
||||
'yes',
|
||||
'no',
|
||||
'zero',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'ten',
|
||||
'phone',
|
||||
'number',
|
||||
'it',
|
||||
'a',
|
||||
'book',
|
||||
'pen',
|
||||
'bag',
|
||||
'key',
|
||||
'where',
|
||||
'from',
|
||||
'this',
|
||||
'mother',
|
||||
'father',
|
||||
'sister',
|
||||
'brother',
|
||||
'friend',
|
||||
'day',
|
||||
'today',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
'time',
|
||||
'oclock',
|
||||
'like',
|
||||
'water',
|
||||
'coffee',
|
||||
'tea',
|
||||
'music',
|
||||
'movies',
|
||||
'please',
|
||||
'say',
|
||||
'again',
|
||||
'speak',
|
||||
'slowly',
|
||||
'alex',
|
||||
'mia',
|
||||
'shen',
|
||||
'hong',
|
||||
'kong',
|
||||
'beijing',
|
||||
'shanghai',
|
||||
};
|
||||
if (words.isEmpty) return true;
|
||||
|
||||
final allowed = targetItemId != null && targetItemId.isNotEmpty
|
||||
? taughtCourseWordsUpToItem(targetItemId)
|
||||
: allLoadedCourseWords();
|
||||
|
||||
// If no course packs have been loaded into the registry yet, fall back gracefully.
|
||||
if (allowed.isEmpty) return true;
|
||||
|
||||
return words.every(allowed.contains);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool usesOnlyA0GeneratedWords(String value) =>
|
||||
usesAllowedCourseWords(value, targetItemId: 'A0-P01');
|
||||
|
||||
List<String>? _stringList(Object? value, {required int max}) {
|
||||
if (value is! List || value.length > max) return null;
|
||||
final result = <String>[];
|
||||
|
||||
@@ -682,18 +682,20 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('目标:${script.goal}'),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) => _TurnBubble(
|
||||
turn: turns[index],
|
||||
state: widget.state,
|
||||
showTranslation: _shownTranslations.contains(index),
|
||||
onToggleTranslation: () => _toggleTurnTranslation(index),
|
||||
RepaintBoundary(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) => _TurnBubble(
|
||||
turn: turns[index],
|
||||
state: widget.state,
|
||||
showTranslation: _shownTranslations.contains(index),
|
||||
onToggleTranslation: () => _toggleTurnTranslation(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -750,13 +752,22 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {
|
||||
if (usedVoice && value != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
onChanged: (value) {
|
||||
final needResetVoice =
|
||||
usedVoice &&
|
||||
value != lastTranscript &&
|
||||
!transcriptEdited;
|
||||
final needClearAi = aiCheck != null || aiCheckError != null;
|
||||
if (needResetVoice || needClearAi) {
|
||||
setState(() {
|
||||
if (needResetVoice) transcriptEdited = true;
|
||||
if (needClearAi) {
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}),
|
||||
},
|
||||
onSubmitted: (_) => send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你的英文回答',
|
||||
@@ -785,21 +796,27 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
controller.text.trim().isEmpty ||
|
||||
checkingWithAi ||
|
||||
waitingForReply
|
||||
? null
|
||||
: _checkWithAi,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
final canCheck =
|
||||
value.text.trim().isNotEmpty &&
|
||||
!checkingWithAi &&
|
||||
!waitingForReply;
|
||||
return OutlinedButton.icon(
|
||||
onPressed: canCheck ? _checkWithAi : null,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(
|
||||
checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
|
||||
@@ -49,7 +49,15 @@ class TodayTaskCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
_TodayTask _todayTask() {
|
||||
final lesson = lessonById(state.activeLessonId);
|
||||
var lessonId = state.activeLessonId;
|
||||
if (state.completedLessonIds.contains(lessonId)) {
|
||||
final next = state.nextIncompleteLessonId;
|
||||
if (next != null) {
|
||||
lessonId = next;
|
||||
}
|
||||
}
|
||||
final lesson = lessonById(lessonId);
|
||||
|
||||
if (state.hasResumableLessonDialogue) {
|
||||
return _TodayTask(
|
||||
label: '未完成',
|
||||
@@ -75,17 +83,39 @@ class TodayTaskCard extends StatelessWidget {
|
||||
}
|
||||
// A0 课程学完、尚未通过阶段评估时,先巩固再评估;通过后回到下一课。
|
||||
if (state.a0LessonsComplete && !state.a0Passed) return _stageTask();
|
||||
|
||||
if (state.allLessonsComplete) {
|
||||
return _TodayTask(
|
||||
label: '已学完',
|
||||
title: '所有课程已完成',
|
||||
note: '你已学完全部课程内容!建议保持日常到期复习或自由情境对话。',
|
||||
action: '去复习',
|
||||
minutes: '10 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
|
||||
final segments = lesson.segments.length;
|
||||
final segment = state.activeSegmentIndexFor(lesson.id) + 1;
|
||||
final segmentIndex = state.activeSegmentIndexFor(lesson.id);
|
||||
final segment = segmentIndex + 1;
|
||||
final isSegmentResuming = segments > 1 && segmentIndex > 0;
|
||||
|
||||
return _TodayTask(
|
||||
label: '今日新课',
|
||||
label: isSegmentResuming ? '继续课程' : '今日新课',
|
||||
title:
|
||||
'第 ${lesson.number} 课 · ${lesson.title}'
|
||||
'${segments > 1 ? ' · 第 $segment/$segments 段' : ''}',
|
||||
note: '预热词汇 → 听说读写 → 对话 → 独立尝试',
|
||||
action: '开始今天的学习',
|
||||
note: isSegmentResuming
|
||||
? '第 $segmentIndex 段已学完;继续本课下一段情境训练。'
|
||||
: '预热词汇 → 听说读写 → 对话 → 独立尝试',
|
||||
action: isSegmentResuming ? '继续学习第 $segment 段' : '开始今天的学习',
|
||||
minutes: '12 分钟',
|
||||
onPressed: onStartLesson,
|
||||
onPressed: () {
|
||||
if (state.activeLessonId != lesson.id) {
|
||||
state.openLesson(lesson.id);
|
||||
}
|
||||
onStartLesson();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.state.addListener(_onStateChange);
|
||||
writingController.text = widget.state.lessonWritingDraft;
|
||||
independentController.text = widget.state.independentAttemptDraft;
|
||||
previewIndex = widget.state.previewIndex
|
||||
@@ -73,11 +74,16 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.state.removeListener(_onStateChange);
|
||||
writingController.dispose();
|
||||
independentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStateChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lesson = lessonById(widget.state.activeLessonId);
|
||||
@@ -230,19 +236,23 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
widget.state.activeSegmentIndexFor(lesson.id) ==
|
||||
lesson.segments.length - 1,
|
||||
nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2,
|
||||
onFinish: () {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
widget.onFinish();
|
||||
},
|
||||
onFinish: _finishOrExit,
|
||||
);
|
||||
}
|
||||
return _LessonScope(
|
||||
title:
|
||||
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
|
||||
onExit: widget.onFinish,
|
||||
onExit: _finishOrExit,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
void _finishOrExit() {
|
||||
if (widget.state.lessonStep == LessonStep.complete) {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
}
|
||||
widget.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
class _LessonScaffold extends StatelessWidget {
|
||||
|
||||
@@ -71,6 +71,9 @@ class _LearningShellState extends State<LearningShell> {
|
||||
void handleBack() {
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
if (widget.state.lessonStep == LessonStep.complete) {
|
||||
widget.state.finishCurrentLessonSegment();
|
||||
}
|
||||
showTab(tab);
|
||||
case _ShellRoute.dialogue:
|
||||
if (dialogueInLesson) {
|
||||
@@ -99,66 +102,6 @@ class _LearningShellState extends State<LearningShell> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
// 换课或换段时重建,避免上一段的听力选择和播放状态沿用过来。
|
||||
key: ValueKey(
|
||||
'${widget.state.activeLessonId}-'
|
||||
'${widget.state.activeSegmentIndexFor(widget.state.activeLessonId)}',
|
||||
),
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
sceneId: sceneId,
|
||||
onFinished: (summary) {
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (summary != null) {
|
||||
showSummary(summary);
|
||||
} else {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
summary: dialogueSummary!,
|
||||
onHome: () => showTab(AppTab.learn),
|
||||
onLesson: showLesson,
|
||||
onRetry: () => showDialogue(scene: sceneId),
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.review),
|
||||
);
|
||||
case _ShellRoute.assessment:
|
||||
body = AssessmentPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onFinished: () => showTab(AppTab.profile),
|
||||
onStartReplacement: showAssessment,
|
||||
);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
body = AssessmentPreparationPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onStart: startAssessment,
|
||||
onBack: () => showTab(AppTab.profile),
|
||||
);
|
||||
case _ShellRoute.tab:
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: route == _ShellRoute.tab && tab == AppTab.learn,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -168,8 +111,69 @@ class _LearningShellState extends State<LearningShell> {
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: widget.state,
|
||||
builder: (context, _) => Scaffold(
|
||||
body: body,
|
||||
builder: (context, _) {
|
||||
final Widget body;
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
// 换课或换段时重建,避免上一段的听力选择和播放状态沿用过来。
|
||||
key: ValueKey(
|
||||
'${widget.state.activeLessonId}-'
|
||||
'${widget.state.activeSegmentIndexFor(widget.state.activeLessonId)}',
|
||||
),
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
sceneId: sceneId,
|
||||
onFinished: (summary) {
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (summary != null) {
|
||||
showSummary(summary);
|
||||
} else {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
summary: dialogueSummary!,
|
||||
onHome: () => showTab(AppTab.learn),
|
||||
onLesson: showLesson,
|
||||
onRetry: () => showDialogue(scene: sceneId),
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.review),
|
||||
);
|
||||
case _ShellRoute.assessment:
|
||||
body = AssessmentPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onFinished: () => showTab(AppTab.profile),
|
||||
onStartReplacement: showAssessment,
|
||||
);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
body = AssessmentPreparationPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onStart: startAssessment,
|
||||
onBack: () => showTab(AppTab.profile),
|
||||
);
|
||||
case _ShellRoute.tab:
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
@@ -201,7 +205,8 @@ class _LearningShellState extends State<LearningShell> {
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -211,7 +216,14 @@ class _LearningShellState extends State<LearningShell> {
|
||||
case AppTab.learn:
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartLesson: showLesson,
|
||||
onStartLesson: () {
|
||||
if (widget.state.completedLessonIds
|
||||
.contains(widget.state.activeLessonId)) {
|
||||
final next = widget.state.nextIncompleteLessonId;
|
||||
if (next != null) widget.state.openLesson(next);
|
||||
}
|
||||
showLesson();
|
||||
},
|
||||
onOpenLesson: (id) {
|
||||
widget.state.openLesson(id);
|
||||
showLesson();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'core/app_state.dart';
|
||||
import 'core/app_theme.dart';
|
||||
@@ -10,6 +11,9 @@ import 'features/shell/learning_shell.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
// A0 is the offline baseline; the later levels load with the app state.
|
||||
await CourseRepository.instance.load(levels: {'A0'});
|
||||
runApp(const KouyuEnglishApp());
|
||||
|
||||
@@ -9,15 +9,59 @@ import '../core/courses/courses.dart';
|
||||
import '../core/voice_service.dart';
|
||||
import 'app_widgets.dart';
|
||||
|
||||
List<VocabularyItem>? _cachedEntries;
|
||||
Map<String, VocabularyItem>? _cachedExactMap;
|
||||
RegExp? _cachedExpression;
|
||||
|
||||
/// Clears the lexicon cache when courses are reloaded or reset.
|
||||
void clearLexiconCache() {
|
||||
_cachedEntries = null;
|
||||
_cachedExactMap = null;
|
||||
_cachedExpression = null;
|
||||
}
|
||||
|
||||
List<VocabularyItem> get courseLexiconEntries {
|
||||
if (_cachedEntries != null) return _cachedEntries!;
|
||||
// Segment words first: they carry the example and IPA a lesson shows.
|
||||
final entries = <VocabularyItem>[
|
||||
...segmentVocabulary.values.expand((items) => items),
|
||||
...allLessons.expand((lesson) => lesson.vocabulary),
|
||||
];
|
||||
final seen = <String>{};
|
||||
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
|
||||
_cachedEntries = entries.where((item) => seen.add(item.word.toLowerCase())).toList()
|
||||
..sort((a, b) => b.word.length.compareTo(a.word.length));
|
||||
return _cachedEntries!;
|
||||
}
|
||||
|
||||
Map<String, VocabularyItem> get _exactLexiconMap {
|
||||
if (_cachedExactMap != null) return _cachedExactMap!;
|
||||
final map = <String, VocabularyItem>{};
|
||||
for (final item in courseLexiconEntries) {
|
||||
final normalized = item.word
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
map.putIfAbsent(normalized, () => item);
|
||||
}
|
||||
_cachedExactMap = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
RegExp? get courseLexiconExpression {
|
||||
if (_cachedExpression != null) return _cachedExpression;
|
||||
final entries = courseLexiconEntries;
|
||||
if (entries.isEmpty) return null;
|
||||
_cachedExpression = RegExp(
|
||||
r'(?<![a-zA-Z0-9])(?:' +
|
||||
entries
|
||||
.map((item) => RegExp.escape(item.word).replaceAll('’', "['’]"))
|
||||
.join('|') +
|
||||
r')(?![a-zA-Z0-9])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
return _cachedExpression;
|
||||
}
|
||||
|
||||
/// Finds a course item by exact query, then by the longest known phrase in it.
|
||||
@@ -29,20 +73,28 @@ VocabularyItem? findCourseLexicon(String text) {
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (normalized.isEmpty) return null;
|
||||
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
|
||||
final word = entry!.word
|
||||
final exact = _exactLexiconMap[normalized];
|
||||
if (exact != null) return exact;
|
||||
|
||||
final entries = courseLexiconEntries;
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
final entry = entries[i];
|
||||
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])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
return pattern.hasMatch(normalized);
|
||||
}, orElse: () => null);
|
||||
if (normalized == word) return entry;
|
||||
if (normalized.contains(word)) {
|
||||
final pattern = RegExp(
|
||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
if (pattern.hasMatch(normalized)) return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extracts all distinct course lexicon phrases/words that appear within [text].
|
||||
@@ -92,6 +144,12 @@ bool isSentenceQuery(String text) {
|
||||
trimmed.length > 25;
|
||||
}
|
||||
|
||||
class _LexiconChunk {
|
||||
const _LexiconChunk({required this.text, this.entry});
|
||||
final String text;
|
||||
final VocabularyItem? entry;
|
||||
}
|
||||
|
||||
/// Inline course text that lets a learner tap a known word or phrase without
|
||||
/// leaving the current task. Longest phrases are matched before their words.
|
||||
class LexiconText extends StatefulWidget {
|
||||
@@ -117,6 +175,46 @@ class LexiconText extends StatefulWidget {
|
||||
class _LexiconTextState extends State<LexiconText> {
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
String selectedText = '';
|
||||
List<_LexiconChunk>? _chunks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_parseChunks();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LexiconText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.text != widget.text) {
|
||||
_parseChunks();
|
||||
}
|
||||
}
|
||||
|
||||
void _parseChunks() {
|
||||
final expression = courseLexiconExpression;
|
||||
if (expression == null) {
|
||||
_chunks = [_LexiconChunk(text: widget.text)];
|
||||
return;
|
||||
}
|
||||
final chunks = <_LexiconChunk>[];
|
||||
var cursor = 0;
|
||||
for (final match in expression.allMatches(widget.text)) {
|
||||
if (match.start > cursor) {
|
||||
chunks.add(
|
||||
_LexiconChunk(text: widget.text.substring(cursor, match.start)),
|
||||
);
|
||||
}
|
||||
final matched = widget.text.substring(match.start, match.end);
|
||||
final entry = findCourseLexicon(matched);
|
||||
chunks.add(_LexiconChunk(text: matched, entry: entry));
|
||||
cursor = match.end;
|
||||
}
|
||||
if (cursor < widget.text.length) {
|
||||
chunks.add(_LexiconChunk(text: widget.text.substring(cursor)));
|
||||
}
|
||||
_chunks = chunks;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -132,26 +230,16 @@ class _LexiconTextState extends State<LexiconText> {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
final entries = courseLexiconEntries;
|
||||
if (entries.isEmpty) return Text(widget.text, style: widget.style);
|
||||
final expression = RegExp(
|
||||
r'(?<![a-zA-Z0-9])(?:' +
|
||||
entries
|
||||
.map((item) => RegExp.escape(item.word).replaceAll('’', "['’]"))
|
||||
.join('|') +
|
||||
r')(?![a-zA-Z0-9])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
if (_chunks == null || _chunks!.isEmpty) {
|
||||
return Text(widget.text, style: widget.style);
|
||||
}
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
var cursor = 0;
|
||||
for (final match in expression.allMatches(widget.text)) {
|
||||
if (match.start > cursor) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
|
||||
}
|
||||
final matched = widget.text.substring(match.start, match.end);
|
||||
final entry = findCourseLexicon(matched);
|
||||
for (final chunk in _chunks!) {
|
||||
final entry = chunk.entry;
|
||||
if (entry == null) {
|
||||
spans.add(TextSpan(text: matched));
|
||||
spans.add(TextSpan(text: chunk.text));
|
||||
} else {
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => showLexiconLookup(
|
||||
@@ -162,7 +250,7 @@ class _LexiconTextState extends State<LexiconText> {
|
||||
_recognizers.add(recognizer);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matched,
|
||||
text: chunk.text,
|
||||
recognizer: recognizer,
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
@@ -172,10 +260,6 @@ class _LexiconTextState extends State<LexiconText> {
|
||||
),
|
||||
);
|
||||
}
|
||||
cursor = match.end;
|
||||
}
|
||||
if (cursor < widget.text.length) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor)));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -1462,4 +1462,22 @@ void main() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('finishCurrentLessonSegment advances activeLessonId to next lesson', () {
|
||||
final state = AppState();
|
||||
expect(state.activeLessonId, 'a0-01');
|
||||
state.completePreview();
|
||||
state.completeListening(recognized: true);
|
||||
state.completeSpeaking();
|
||||
state.completeReading();
|
||||
state.completeWriting(assisted: false, rawAnswer: 'hello');
|
||||
state.completeLessonDialogue();
|
||||
state.completeIndependentAttempt(assisted: false, rawAnswer: 'hello');
|
||||
expect(state.lessonCanComplete, isTrue);
|
||||
|
||||
state.finishCurrentLessonSegment();
|
||||
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
expect(state.activeLessonId, 'a0-02');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ void main() {
|
||||
expect(last, startsWith('My name is Alex.'));
|
||||
expect(
|
||||
last,
|
||||
endsWith('After your line the learner has to: say where they are from.'),
|
||||
contains('After your line the learner has to: say where they are from.'),
|
||||
);
|
||||
expect(
|
||||
last,
|
||||
endsWith('Do not repeat any questions or greetings already asked or answered.'),
|
||||
);
|
||||
expect(messages![0]['content'], isNot(contains('Ask where the learner')));
|
||||
});
|
||||
@@ -127,6 +131,7 @@ void main() {
|
||||
);
|
||||
expect(a, b);
|
||||
expect(a, contains('CEFR A1'));
|
||||
expect(a, contains('Strictly do not repeat any question'));
|
||||
expect(a, endsWith('Taught language: Excuse me.; Can you help me?'));
|
||||
expect(
|
||||
AiService.dialogueSystemPrompt(level: 'A0', allowedLanguage: const []),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:kouyu_english/features/review/review_page.dart';
|
||||
import 'package:kouyu_english/features/shell/learning_shell.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:kouyu_english/core/courses/course_repository.dart';
|
||||
import 'support/app_assets.dart';
|
||||
|
||||
void main() {
|
||||
@@ -221,4 +222,83 @@ void main() {
|
||||
expect(find.text('四技能评估'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'LessonFlow on complete step: back button finalizes segment and advances lesson',
|
||||
(tester) async {
|
||||
await tester.runAsync(
|
||||
() => CourseRepository.instance.load(levels: {'A0'}),
|
||||
);
|
||||
final state = AppState()..finishOnboarding();
|
||||
expect(state.activeLessonId, 'a0-01');
|
||||
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap '开始今天的学习' to enter LessonFlow
|
||||
await tester.tap(find.text('开始今天的学习'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Complete all steps of a0-01
|
||||
state.completePreview();
|
||||
state.completeListening(recognized: true);
|
||||
state.completeSpeaking();
|
||||
state.completeReading();
|
||||
state.completeWriting(assisted: false, rawAnswer: 'hello');
|
||||
state.completeLessonDialogue();
|
||||
state.completeIndependentAttempt(assisted: false, rawAnswer: 'hello');
|
||||
expect(state.lessonStep, LessonStep.complete);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// We should be in lesson flow complete screen
|
||||
expect(find.text('本段已保存'), findsOneWidget);
|
||||
expect(find.text('回到首页'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap the top-left AppBar back button instead of '回到首页'
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should be back on the 学习 tab, and lesson must have advanced to a0-02
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
expect(state.activeLessonId, 'a0-02');
|
||||
expect(find.textContaining('第 2 课 · '), findsWidgets);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'TodayTaskCard reflects multi-segment progress and completed fallback',
|
||||
(tester) async {
|
||||
await tester.runAsync(
|
||||
() => CourseRepository.instance.load(levels: {'A0'}),
|
||||
);
|
||||
final state = AppState()..finishOnboarding();
|
||||
state.activeLessonId = 'a0-04'; // a0-04 has 3 segments
|
||||
|
||||
await tester.pumpWidget(MaterialApp(home: LearningShell(state: state)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Segment 1 (initial)
|
||||
expect(find.text('今日新课'), findsOneWidget);
|
||||
expect(find.text('开始今天的学习'), findsOneWidget);
|
||||
|
||||
// Now complete segment 1
|
||||
state.completeSegment('a0-04', 0);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Segment 2 (resuming)
|
||||
expect(find.text('继续课程'), findsOneWidget);
|
||||
expect(find.text('继续学习第 2 段'), findsOneWidget);
|
||||
expect(find.textContaining('第 2/3 段'), findsWidgets);
|
||||
|
||||
// If activeLessonId is marked as completed, card targets next incomplete
|
||||
state.completedLessonIds.add('a0-04');
|
||||
state.notifyListeners();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should target a0-01 or next incomplete, not show completed lesson
|
||||
expect(find.text('继续课程'), findsNothing);
|
||||
expect(find.textContaining('第 1 课 · '), findsWidgets);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user