feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Advisory evaluation for open-ended learner answers. Local rules still
|
||||
/// decide whether an attempt counts as evidence or changes learning state.
|
||||
/// Evaluation for open-ended learner answers. Lesson submissions combine this
|
||||
/// verdict with local task rules before recording independent evidence.
|
||||
class AiAnswerEvaluationCapability {
|
||||
const AiAnswerEvaluationCapability._(this._service);
|
||||
|
||||
|
||||
@@ -964,8 +964,9 @@ class AiService {
|
||||
'''Evaluate a beginner's open-ended English writing response using the input JSON.
|
||||
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.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 rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||||
Assess only whether the learner expressed input.taskPrompt. Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
||||
verdict must be accepted, rewrite, or uncertain. Use accepted only when the answer meets input.taskPrompt and has no spelling or grammar mistakes; ignore minor capitalisation and final punctuation. Use rewrite when the task is missed or there is a spelling or grammar mistake. Use uncertain when the intended meaning cannot be determined.
|
||||
feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions of actual issues.
|
||||
Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
||||
final content = await _requestStructured(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
@@ -985,8 +986,9 @@ Assess only whether the learner expressed input.taskPrompt. Do not claim pronunc
|
||||
}
|
||||
|
||||
/// Checks a review answer or dialogue turn for spelling and grammar. It
|
||||
/// reuses the writing feedback schema, keyed by [answerId], and stays
|
||||
/// advisory: the local check still decides whether the answer counts.
|
||||
/// reuses the writing feedback schema, keyed by [answerId]. Lesson answers
|
||||
/// need both a local task match and an accepted verdict for independent
|
||||
/// evidence; the caller makes that decision on-device.
|
||||
Future<WritingAiFeedback?> answerFeedback({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
|
||||
@@ -53,12 +53,19 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
LessonStep lessonStep = LessonStep.preview;
|
||||
int previewIndex = 0;
|
||||
bool lessonListeningComplete = false;
|
||||
bool listeningAudioPlayed = false;
|
||||
bool listeningAudioUnavailable = false;
|
||||
ListeningSupport listeningSupport = ListeningSupport.none;
|
||||
int listeningAttempts = 0;
|
||||
bool sentenceBuildUsed = false;
|
||||
int sentenceBuildIndex = 0;
|
||||
bool lessonSpeakingComplete = false;
|
||||
bool lessonReadingComplete = false;
|
||||
bool lessonWritingComplete = false;
|
||||
bool lessonDialogueComplete = false;
|
||||
bool independentAttemptComplete = false;
|
||||
bool independentAttemptAssisted = false;
|
||||
bool independentAttemptPending = false;
|
||||
bool independentAttemptSpoken = false;
|
||||
String lessonWritingDraft = '';
|
||||
String independentAttemptDraft = '';
|
||||
@@ -101,7 +108,8 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
}
|
||||
|
||||
class AppState extends _AppStateData
|
||||
with _ReviewAndMastery,
|
||||
with
|
||||
_ReviewAndMastery,
|
||||
_ReceptiveWords,
|
||||
_AssessmentProgress,
|
||||
_LessonProgress,
|
||||
@@ -245,10 +253,14 @@ class AppState extends _AppStateData
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Applies the placement result. The start may land on any loaded lesson
|
||||
/// across A0–B1, and placement never counts as passing a lesson.
|
||||
/// Placement never marks an earlier lesson as passed or opens draft content.
|
||||
void setPlacementStartLesson(String lessonId) {
|
||||
if (!allLessons.any((lesson) => lesson.id == lessonId)) return;
|
||||
if (!allLessons.any(
|
||||
(lesson) =>
|
||||
lesson.id == lessonId && lesson.status == ContentStatus.approved,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
placementStartLessonId = lessonId;
|
||||
activeLessonId = lessonId;
|
||||
_resetLessonFlow();
|
||||
@@ -312,12 +324,19 @@ class AppState extends _AppStateData
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonListeningComplete = false;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
lessonSpeakingComplete = false;
|
||||
lessonReadingComplete = false;
|
||||
lessonWritingComplete = false;
|
||||
lessonDialogueComplete = false;
|
||||
independentAttemptComplete = false;
|
||||
independentAttemptAssisted = false;
|
||||
independentAttemptPending = false;
|
||||
independentAttemptSpoken = false;
|
||||
mastery.clear();
|
||||
notifyListeners();
|
||||
|
||||
@@ -90,6 +90,7 @@ mixin _LessonProgress
|
||||
final lessons = allLessons;
|
||||
final index = lessons.indexWhere((lesson) => lesson.id == id);
|
||||
if (index < 0) return false;
|
||||
if (lessons[index].status != ContentStatus.approved) return false;
|
||||
// The placement start and everything before it are open, so a learner
|
||||
// placed further ahead can still go back to easier lessons.
|
||||
if (index == 0 || index <= _lessonIndex(placementStartLessonId)) {
|
||||
@@ -110,8 +111,9 @@ mixin _LessonProgress
|
||||
bool get a0LessonsComplete =>
|
||||
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
bool get allLessonsComplete =>
|
||||
allLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
bool get allLessonsComplete => allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
void openLesson(String id) {
|
||||
if (!isLessonUnlocked(id)) return;
|
||||
@@ -134,35 +136,89 @@ mixin _LessonProgress
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningPlayback({bool slow = false}) {
|
||||
listeningAudioPlayed = true;
|
||||
listeningAudioUnavailable = false;
|
||||
if (slow && listeningSupport == ListeningSupport.none) {
|
||||
listeningSupport = ListeningSupport.slow;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningAudioUnavailable() {
|
||||
listeningAudioUnavailable = true;
|
||||
listeningSupport = ListeningSupport.transcript;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void revealListeningTranscript() {
|
||||
listeningSupport = ListeningSupport.transcript;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningMiss() {
|
||||
listeningAttempts++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordSentenceBuildProgress(int index) {
|
||||
sentenceBuildUsed = true;
|
||||
sentenceBuildIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// [recognized] is true when the learner picked the right meaning on the
|
||||
/// first try, which is recognition evidence for the segment's targets.
|
||||
/// first try, which is recognition evidence for explicitly linked items.
|
||||
void completeListening({bool recognized = false}) {
|
||||
_introduceLessonTargets();
|
||||
if (recognized && !lessonListeningComplete) {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
final independent =
|
||||
recognized &&
|
||||
listeningAudioPlayed &&
|
||||
listeningSupport == ListeningSupport.none &&
|
||||
listeningAttempts == 0;
|
||||
if (!lessonListeningComplete) {
|
||||
final targetIds = activityBySegmentId(_activeSegmentId).listeningItemIds;
|
||||
_recordActivityEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: 'listening-check',
|
||||
skill: '听辨识别',
|
||||
outcome: EvidenceKind.independentSuccess,
|
||||
skill: targetIds.isEmpty ? '听力理解' : '听辨识别',
|
||||
outcome: independent
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.assisted,
|
||||
assisted: !independent,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: listeningAttempts == 0,
|
||||
attemptCount: listeningAttempts + 1,
|
||||
);
|
||||
}
|
||||
lessonListeningComplete = true;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
lessonStep = LessonStep.speaking;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Follow-reading repeats a model sentence, so it is always assisted
|
||||
/// practice and never independent speaking evidence.
|
||||
void completeSpeaking() {
|
||||
void completeSpeaking({bool practiced = false}) {
|
||||
if (lessonSpeakingComplete) return;
|
||||
lessonSpeakingComplete = true;
|
||||
lessonStep = LessonStep.reading;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
taskSuffix: 'speaking',
|
||||
skill: '口语表达',
|
||||
outcome: EvidenceKind.assisted,
|
||||
assisted: true,
|
||||
);
|
||||
final targetIds = activityBySegmentId(_activeSegmentId).speakingItemIds;
|
||||
if (practiced) {
|
||||
_recordActivityEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: 'speaking',
|
||||
skill: targetIds.isEmpty ? '句子跟读' : '口语表达',
|
||||
outcome: EvidenceKind.assisted,
|
||||
assisted: true,
|
||||
usedSentenceBuild: sentenceBuildUsed,
|
||||
);
|
||||
}
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -207,7 +263,11 @@ mixin _LessonProgress
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeWriting({bool assisted = false, String? rawAnswer}) {
|
||||
void completeWriting({
|
||||
bool assisted = false,
|
||||
bool verified = true,
|
||||
String? rawAnswer,
|
||||
}) {
|
||||
lessonWritingComplete = true;
|
||||
lessonWritingDraft = '';
|
||||
lessonStep = LessonStep.dialogue;
|
||||
@@ -215,7 +275,9 @@ mixin _LessonProgress
|
||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||
taskSuffix: 'writing',
|
||||
skill: '写作表达',
|
||||
outcome: assisted
|
||||
outcome: !verified
|
||||
? EvidenceKind.pending
|
||||
: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
@@ -239,6 +301,7 @@ mixin _LessonProgress
|
||||
|
||||
void completeIndependentAttempt({
|
||||
required bool assisted,
|
||||
bool verified = true,
|
||||
bool spoken = false,
|
||||
String? rawAnswer,
|
||||
String? recordingPath,
|
||||
@@ -246,14 +309,17 @@ mixin _LessonProgress
|
||||
independentAttemptComplete = true;
|
||||
independentAttemptDraft = '';
|
||||
independentAttemptAssisted = assisted;
|
||||
independentAttemptSpoken = spoken && !assisted;
|
||||
independentAttemptPending = !verified;
|
||||
independentAttemptSpoken = spoken && !assisted && verified;
|
||||
lessonStep = LessonStep.complete;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||
taskSuffix: 'independent',
|
||||
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
||||
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
||||
outcome: assisted
|
||||
outcome: !verified
|
||||
? EvidenceKind.pending
|
||||
: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
@@ -361,7 +427,9 @@ mixin _LessonProgress
|
||||
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
|
||||
|
||||
String? _nextIncompleteLessonId() {
|
||||
final lessons = allLessons;
|
||||
final lessons = allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.toList();
|
||||
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
||||
final ordered = [...lessons.skip(start), ...lessons.take(start)];
|
||||
return ordered
|
||||
@@ -374,12 +442,19 @@ mixin _LessonProgress
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonListeningComplete = false;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
lessonSpeakingComplete = false;
|
||||
lessonReadingComplete = false;
|
||||
lessonWritingComplete = false;
|
||||
lessonDialogueComplete = false;
|
||||
independentAttemptComplete = false;
|
||||
independentAttemptAssisted = false;
|
||||
independentAttemptPending = false;
|
||||
independentAttemptSpoken = false;
|
||||
lessonWritingDraft = '';
|
||||
independentAttemptDraft = '';
|
||||
@@ -513,6 +588,10 @@ mixin _LessonProgress
|
||||
String inputMode = 'text',
|
||||
String? recordingPath,
|
||||
bool assisted = false,
|
||||
ListeningSupport? listeningSupport,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
bool usedSentenceBuild = false,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (var index = 0; index < targetIds.length; index++) {
|
||||
@@ -529,6 +608,10 @@ mixin _LessonProgress
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
),
|
||||
);
|
||||
// The row goes in first: status is derived from the attempt history.
|
||||
@@ -536,6 +619,52 @@ mixin _LessonProgress
|
||||
}
|
||||
}
|
||||
|
||||
/// A comprehension question or model sentence without an exact core-item
|
||||
/// link is still visible in learning records, but cannot change mastery.
|
||||
void _recordActivityEvidence({
|
||||
required List<String> targetIds,
|
||||
required String taskSuffix,
|
||||
required String skill,
|
||||
required EvidenceKind outcome,
|
||||
bool assisted = false,
|
||||
ListeningSupport? listeningSupport,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
bool usedSentenceBuild = false,
|
||||
}) {
|
||||
if (targetIds.isNotEmpty) {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: taskSuffix,
|
||||
skill: skill,
|
||||
outcome: outcome,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'lesson-$_activeSegmentId-$taskSuffix-${now.microsecondsSinceEpoch}',
|
||||
itemId: 'segment:$_activeSegmentId',
|
||||
taskId: 'lesson-$_activeSegmentId-$taskSuffix',
|
||||
skill: skill,
|
||||
inputMode: 'text',
|
||||
outcome: outcome,
|
||||
createdAt: now,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _introduceLessonTargets() {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
|
||||
@@ -139,11 +139,19 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
void completeReview(
|
||||
ReviewItem item, {
|
||||
required bool assisted,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
ListeningSupport? listeningSupport,
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
assisted =
|
||||
assisted ||
|
||||
(listeningSupport != null &&
|
||||
listeningSupport != ListeningSupport.none) ||
|
||||
(attemptCount != null && attemptCount > 1);
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
@@ -175,7 +183,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
: current.successfulReviews;
|
||||
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
|
||||
// review is due one day after teaching, then 2, 4 and 7 days apart, and
|
||||
// every 30 days once all four checkpoints are passed.
|
||||
// the first mastered audit is 30 days later; later clean, first-try
|
||||
// audits can stretch to 45 and then 60 days.
|
||||
const intervals = [2, 4, 7];
|
||||
final intervalIndex = (nextSuccesses - 1)
|
||||
.clamp(0, intervals.length - 1)
|
||||
@@ -187,7 +196,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
masteryItem?.status == MasteryStatus.master
|
||||
? 7
|
||||
: nextSuccesses >= 4
|
||||
? 30
|
||||
? (firstTry == true && nextSuccesses >= 5
|
||||
? (nextSuccesses >= 6 ? 60 : 45)
|
||||
: 30)
|
||||
: intervals[intervalIndex];
|
||||
// The next check of a core item uses a different reviewed situation, so
|
||||
// later successes also show the item works outside the lesson's context.
|
||||
@@ -211,6 +222,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
assisted: assisted,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
listeningSupport: listeningSupport,
|
||||
rawAnswer: rawAnswer,
|
||||
inputMode: inputMode,
|
||||
originalTranscript: originalTranscript,
|
||||
@@ -273,7 +287,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
/// next real check still happens tomorrow on a different task.
|
||||
void _scheduleSessionRetry(ReviewItem item) {
|
||||
if (reviewQueue.any(
|
||||
(candidate) => candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
(candidate) =>
|
||||
candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
@@ -425,6 +440,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
ListeningSupport? listeningSupport,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final spoken = originalTranscript != null;
|
||||
@@ -444,6 +462,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
originalTranscript: originalTranscript,
|
||||
transcriptConfirmed: spoken && originalTranscript == rawAnswer,
|
||||
transcriptEdited: spoken && originalTranscript != rawAnswer,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
listeningSupport: listeningSupport,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -523,8 +544,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(item) =>
|
||||
item.id == variant.targetItemId &&
|
||||
item.kind == ReviewKind.checkpoint,
|
||||
item.id == variant.targetItemId && item.kind == ReviewKind.checkpoint,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
@@ -620,8 +640,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
if (!isReview) continue;
|
||||
// The recognition gate and the contrast question are 「认识」 evidence
|
||||
// only; producing the item is what earns a checkpoint.
|
||||
if (event.skill == recognitionGateSkill ||
|
||||
event.skill == contrastSkill) {
|
||||
if (event.skill == recognitionGateSkill || event.skill == contrastSkill) {
|
||||
continue;
|
||||
}
|
||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||
|
||||
@@ -142,6 +142,17 @@ extension _AppStateSnapshot on AppState {
|
||||
);
|
||||
}
|
||||
lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false;
|
||||
listeningAudioPlayed = data['listeningAudioPlayed'] as bool? ?? false;
|
||||
listeningAudioUnavailable =
|
||||
data['listeningAudioUnavailable'] as bool? ?? false;
|
||||
listeningSupport = _enumValue(
|
||||
ListeningSupport.values,
|
||||
data['listeningSupport'] as String?,
|
||||
ListeningSupport.none,
|
||||
);
|
||||
listeningAttempts = data['listeningAttempts'] as int? ?? 0;
|
||||
sentenceBuildUsed = data['sentenceBuildUsed'] as bool? ?? false;
|
||||
sentenceBuildIndex = data['sentenceBuildIndex'] as int? ?? 0;
|
||||
lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false;
|
||||
lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false;
|
||||
lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false;
|
||||
@@ -150,6 +161,8 @@ extension _AppStateSnapshot on AppState {
|
||||
data['independentAttemptComplete'] as bool? ?? false;
|
||||
independentAttemptAssisted =
|
||||
data['independentAttemptAssisted'] as bool? ?? false;
|
||||
independentAttemptPending =
|
||||
data['independentAttemptPending'] as bool? ?? false;
|
||||
independentAttemptSpoken =
|
||||
data['independentAttemptSpoken'] as bool? ?? false;
|
||||
lessonWritingDraft = data['lessonWritingDraft'] as String? ?? '';
|
||||
@@ -190,6 +203,16 @@ extension _AppStateSnapshot on AppState {
|
||||
transcriptConfirmed:
|
||||
item['transcriptConfirmed'] as bool? ?? false,
|
||||
transcriptEdited: item['transcriptEdited'] as bool? ?? false,
|
||||
listeningSupport: item['listeningSupport'] == null
|
||||
? null
|
||||
: _enumValue(
|
||||
ListeningSupport.values,
|
||||
item['listeningSupport'] as String?,
|
||||
ListeningSupport.none,
|
||||
),
|
||||
firstTry: item['firstTry'] as bool?,
|
||||
attemptCount: item['attemptCount'] as int?,
|
||||
usedSentenceBuild: item['usedSentenceBuild'] as bool? ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -234,15 +257,17 @@ extension _AppStateSnapshot on AppState {
|
||||
misses: item['misses'] as int? ?? 0,
|
||||
// Snapshots written before the spacing ladder carry no step;
|
||||
// place them on the rung their status implies.
|
||||
step: item['step'] as int? ?? switch (_enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
)) {
|
||||
WordStatus.newWord => 0,
|
||||
WordStatus.recognized => 1,
|
||||
WordStatus.familiar => 4,
|
||||
},
|
||||
step:
|
||||
item['step'] as int? ??
|
||||
switch (_enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
)) {
|
||||
WordStatus.newWord => 0,
|
||||
WordStatus.recognized => 1,
|
||||
WordStatus.familiar => 4,
|
||||
},
|
||||
startedAt: DateTime.tryParse(
|
||||
item['startedAt'] as String? ?? '',
|
||||
),
|
||||
@@ -396,12 +421,19 @@ extension _AppStateSnapshot on AppState {
|
||||
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
||||
'activeSegmentIndexes': activeSegmentIndexes,
|
||||
'lessonListeningComplete': lessonListeningComplete,
|
||||
'listeningAudioPlayed': listeningAudioPlayed,
|
||||
'listeningAudioUnavailable': listeningAudioUnavailable,
|
||||
'listeningSupport': listeningSupport.name,
|
||||
'listeningAttempts': listeningAttempts,
|
||||
'sentenceBuildUsed': sentenceBuildUsed,
|
||||
'sentenceBuildIndex': sentenceBuildIndex,
|
||||
'lessonSpeakingComplete': lessonSpeakingComplete,
|
||||
'lessonReadingComplete': lessonReadingComplete,
|
||||
'lessonWritingComplete': lessonWritingComplete,
|
||||
'lessonDialogueComplete': lessonDialogueComplete,
|
||||
'independentAttemptComplete': independentAttemptComplete,
|
||||
'independentAttemptAssisted': independentAttemptAssisted,
|
||||
'independentAttemptPending': independentAttemptPending,
|
||||
'independentAttemptSpoken': independentAttemptSpoken,
|
||||
'lessonWritingDraft': lessonWritingDraft,
|
||||
'independentAttemptDraft': independentAttemptDraft,
|
||||
@@ -464,6 +496,10 @@ extension _AppStateSnapshot on AppState {
|
||||
'originalTranscript': entry.originalTranscript,
|
||||
'transcriptConfirmed': entry.transcriptConfirmed,
|
||||
'transcriptEdited': entry.transcriptEdited,
|
||||
'listeningSupport': entry.listeningSupport?.name,
|
||||
'firstTry': entry.firstTry,
|
||||
'attemptCount': entry.attemptCount,
|
||||
'usedSentenceBuild': entry.usedSentenceBuild,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@@ -549,7 +585,11 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
|
||||
hint: data['hint'] as String,
|
||||
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
||||
skill: data['skill'] as String,
|
||||
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
|
||||
kind: _enumValue(
|
||||
ReviewKind.values,
|
||||
data['kind'] as String?,
|
||||
ReviewKind.checkpoint,
|
||||
),
|
||||
attempts: data['attempts'] as int? ?? 0,
|
||||
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
||||
variantIndex: data['variantIndex'] as int? ?? 0,
|
||||
|
||||
@@ -141,19 +141,23 @@ mixin _ReceptiveWords on _AppStateData {
|
||||
return WordStatus.recognized;
|
||||
}
|
||||
|
||||
DateTime _tomorrow(DateTime now) =>
|
||||
DateTime(now.year, now.month, now.day).add(const Duration(days: 1, hours: 9));
|
||||
DateTime _tomorrow(DateTime now) => DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).add(const Duration(days: 1, hours: 9));
|
||||
|
||||
/// Words whose check has come due, weakest first.
|
||||
List<String> get dueWords {
|
||||
final now = DateTime.now();
|
||||
final due = wordKnowledge.values
|
||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
|
||||
return a.dueAt!.compareTo(b.dueAt!);
|
||||
});
|
||||
final due =
|
||||
wordKnowledge.values
|
||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
|
||||
return a.dueAt!.compareTo(b.dueAt!);
|
||||
});
|
||||
return [for (final word in due) word.id];
|
||||
}
|
||||
|
||||
@@ -165,10 +169,13 @@ mixin _ReceptiveWords on _AppStateData {
|
||||
|
||||
/// Words that have been missed and are not 「熟悉」 yet.
|
||||
List<String> get shakyWords {
|
||||
final shaky = wordKnowledge.values
|
||||
.where((word) => word.misses > 0 && word.status != WordStatus.familiar)
|
||||
.toList()
|
||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||
final shaky =
|
||||
wordKnowledge.values
|
||||
.where(
|
||||
(word) => word.misses > 0 && word.status != WordStatus.familiar,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||
return [for (final word in shaky) word.id];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ class AssessmentTask {
|
||||
skill: AssessmentSkill.values.byName(json['skill'] as String),
|
||||
prompt: json['prompt'] as String? ?? '',
|
||||
audio: json['audio'] as String?,
|
||||
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
|
||||
choices: [
|
||||
for (final choice in json['choices'] as List? ?? const []) '$choice',
|
||||
],
|
||||
answerIndex: json['answerIndex'] as int?,
|
||||
accept: [
|
||||
for (final group in json['accept'] as List? ?? const [])
|
||||
@@ -102,10 +104,7 @@ AssessmentPack? replacementFor(String packId) =>
|
||||
/// The pack whose results [packId] counts toward: the original of a
|
||||
/// replacement pack, otherwise [packId] itself.
|
||||
String canonicalAssessmentPackId(String packId) =>
|
||||
assessmentPacks
|
||||
.where((pack) => pack.id == packId)
|
||||
.firstOrNull
|
||||
?.replaces ??
|
||||
assessmentPacks.where((pack) => pack.id == packId).firstOrNull?.replaces ??
|
||||
packId;
|
||||
|
||||
/// Local check of an open task with the same rule engine as the lessons: it
|
||||
|
||||
@@ -63,13 +63,7 @@ void registerSavedWord(
|
||||
required String zh,
|
||||
required String level,
|
||||
}) {
|
||||
receptiveWordRegistry[id] = (
|
||||
level: level,
|
||||
unit: '',
|
||||
en: en,
|
||||
zh: zh,
|
||||
ipa: '',
|
||||
);
|
||||
receptiveWordRegistry[id] = (level: level, unit: '', en: en, zh: zh, ipa: '');
|
||||
}
|
||||
|
||||
/// Registers one word of the standalone word bank. Unlike a unit's receptive
|
||||
@@ -191,9 +185,10 @@ WordQuestion? receptiveQuestion(
|
||||
if (wrong.isEmpty) return null;
|
||||
// A different variant asks the same word against other meanings, which is
|
||||
// what 「换题、换语境」 means for a word this small.
|
||||
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
|
||||
.take(2)
|
||||
.toList();
|
||||
final rotated = [
|
||||
...wrong.skip(variant % wrong.length),
|
||||
...wrong.take(variant % wrong.length),
|
||||
].take(2).toList();
|
||||
// Asked from the meaning, the options are spellings and the prompt is the
|
||||
// Chinese; asked either other way, it is the other way round.
|
||||
final asksSpelling = mode == WordAskMode.recall;
|
||||
@@ -339,7 +334,6 @@ const spokenRecallSkill = '口头回忆';
|
||||
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
|
||||
const recognitionGateSkill = '听辨识别';
|
||||
|
||||
|
||||
/// The skill of a contrast question: a situation is described and the learner
|
||||
/// picks which taught sentence it calls for. It shows the learner knows *when*
|
||||
/// to say something, which is not the same as being able to produce it, so it
|
||||
|
||||
@@ -12,7 +12,6 @@ class LessonSegment {
|
||||
this.estimatedMinutes = 12,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final List<String> targetItemIds;
|
||||
final List<String> previewItemIds;
|
||||
@@ -86,8 +85,12 @@ class LessonActivity {
|
||||
required this.listening,
|
||||
required this.listeningQuestion,
|
||||
required this.answers,
|
||||
this.listeningItemIds = const [],
|
||||
required this.speaking,
|
||||
this.speakingTip = '',
|
||||
this.speakingChunks = const [],
|
||||
this.speakingItemIds = const [],
|
||||
this.speakingExamples = const [],
|
||||
required this.reading,
|
||||
required this.readingQuestion,
|
||||
required this.readingAnswer,
|
||||
@@ -100,14 +103,17 @@ class LessonActivity {
|
||||
required this.independentHelp,
|
||||
});
|
||||
|
||||
|
||||
final String listening;
|
||||
final String listeningQuestion;
|
||||
final List<String> answers;
|
||||
final List<String> listeningItemIds;
|
||||
final String speaking;
|
||||
|
||||
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
||||
final String speakingTip;
|
||||
final List<String> speakingChunks;
|
||||
final List<String> speakingItemIds;
|
||||
final List<String> speakingExamples;
|
||||
final String reading;
|
||||
final String readingQuestion;
|
||||
final String readingAnswer;
|
||||
@@ -135,7 +141,6 @@ class LessonDialogue {
|
||||
this.taskLabels = const [],
|
||||
});
|
||||
|
||||
|
||||
final String goal;
|
||||
final List<String> prompts;
|
||||
final List<String> hints;
|
||||
@@ -165,7 +170,6 @@ class DialogueScene {
|
||||
this.unlockAfterLessonId,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String summary;
|
||||
|
||||
@@ -214,8 +214,12 @@ class PackSegment {
|
||||
required this.listeningText,
|
||||
required this.listeningQuestion,
|
||||
required this.listeningOptions,
|
||||
this.listeningItemIds = const [],
|
||||
required this.speakingText,
|
||||
required this.speakingTip,
|
||||
this.speakingChunks = const [],
|
||||
this.speakingItemIds = const [],
|
||||
this.speakingExamples = const [],
|
||||
required this.readingText,
|
||||
required this.readingQuestion,
|
||||
required this.readingOptions,
|
||||
@@ -241,9 +245,13 @@ class PackSegment {
|
||||
final String listeningText;
|
||||
final String listeningQuestion;
|
||||
final List<String> listeningOptions;
|
||||
final List<String> listeningItemIds;
|
||||
|
||||
final String speakingText;
|
||||
final String speakingTip;
|
||||
final List<String> speakingChunks;
|
||||
final List<String> speakingItemIds;
|
||||
final List<String> speakingExamples;
|
||||
|
||||
final String readingText;
|
||||
final String readingQuestion;
|
||||
@@ -288,8 +296,15 @@ class PackSegment {
|
||||
listeningText: listening['text'] as String? ?? '',
|
||||
listeningQuestion: listening['question'] as String? ?? '',
|
||||
listeningOptions: _stringList(listening['options']),
|
||||
listeningItemIds: _stringList(listening['itemIds']),
|
||||
speakingText: firstSpeaking['text'] as String? ?? '',
|
||||
speakingTip: firstSpeaking['tip'] as String? ?? '',
|
||||
speakingChunks: _stringList(firstSpeaking['chunks']),
|
||||
speakingItemIds: _stringList(firstSpeaking['itemIds']),
|
||||
speakingExamples: [
|
||||
for (final line in speaking.skip(1))
|
||||
if (line['text'] is String) line['text'] as String,
|
||||
],
|
||||
readingText: reading['text'] as String? ?? '',
|
||||
readingQuestion: reading['question'] as String? ?? '',
|
||||
readingOptions: _stringList(reading['options']),
|
||||
|
||||
@@ -225,8 +225,12 @@ class CourseRepository {
|
||||
answers: seg.listeningOptions.isNotEmpty
|
||||
? seg.listeningOptions
|
||||
: const [''],
|
||||
listeningItemIds: seg.listeningItemIds,
|
||||
speaking: seg.speakingText,
|
||||
speakingTip: seg.speakingTip,
|
||||
speakingChunks: seg.speakingChunks,
|
||||
speakingItemIds: seg.speakingItemIds,
|
||||
speakingExamples: seg.speakingExamples,
|
||||
reading: seg.readingText,
|
||||
readingQuestion: seg.readingQuestion,
|
||||
readingAnswer: seg.readingOptions.isNotEmpty
|
||||
|
||||
@@ -84,13 +84,7 @@ DialogueTurnDecision resolveDialogueTurnDecision({
|
||||
: DialogueValidationSource.local;
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: true,
|
||||
evidenceOutcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: aiWasAttempted
|
||||
? EvidenceKind.pending
|
||||
: isFreeScene
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.pending,
|
||||
evidenceOutcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||
validationSource: source,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
|
||||
@@ -52,7 +52,9 @@ class LocalSnapshotStore {
|
||||
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
|
||||
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
|
||||
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
transcript_edited INTEGER NOT NULL DEFAULT 0
|
||||
transcript_edited INTEGER NOT NULL DEFAULT 0,
|
||||
listening_support TEXT, first_try INTEGER, attempt_count INTEGER,
|
||||
used_sentence_build INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await _ensureColumn(
|
||||
@@ -73,6 +75,19 @@ class LocalSnapshotStore {
|
||||
name: 'transcript_edited',
|
||||
definition: 'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
for (final column in const <String, String>{
|
||||
'listening_support': 'TEXT',
|
||||
'first_try': 'INTEGER',
|
||||
'attempt_count': 'INTEGER',
|
||||
'used_sentence_build': 'INTEGER NOT NULL DEFAULT 0',
|
||||
}.entries) {
|
||||
await _ensureColumn(
|
||||
executor,
|
||||
table: 'attempt_evidence',
|
||||
name: column.key,
|
||||
definition: column.value,
|
||||
);
|
||||
}
|
||||
await executor.runCustom('''
|
||||
CREATE TABLE IF NOT EXISTS mastery_items (
|
||||
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
|
||||
@@ -193,7 +208,8 @@ class LocalSnapshotStore {
|
||||
(await executor.runSelect(
|
||||
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
|
||||
created_at, raw_answer, recording_path, assisted, variant_index,
|
||||
original_transcript, transcript_confirmed, transcript_edited
|
||||
original_transcript, transcript_confirmed, transcript_edited,
|
||||
listening_support, first_try, attempt_count, used_sentence_build
|
||||
FROM attempt_evidence ORDER BY created_at''',
|
||||
const [],
|
||||
))
|
||||
@@ -213,6 +229,12 @@ class LocalSnapshotStore {
|
||||
'originalTranscript': row['original_transcript'],
|
||||
'transcriptConfirmed': row['transcript_confirmed'] == 1,
|
||||
'transcriptEdited': row['transcript_edited'] == 1,
|
||||
'listeningSupport': row['listening_support'],
|
||||
'firstTry': row['first_try'] == null
|
||||
? null
|
||||
: row['first_try'] == 1,
|
||||
'attemptCount': row['attempt_count'],
|
||||
'usedSentenceBuild': row['used_sentence_build'] == 1,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
@@ -333,8 +355,9 @@ class LocalSnapshotStore {
|
||||
'''INSERT INTO attempt_evidence(
|
||||
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
|
||||
raw_answer, recording_path, assisted, variant_index,
|
||||
original_transcript, transcript_confirmed, transcript_edited
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
original_transcript, transcript_confirmed, transcript_edited,
|
||||
listening_support, first_try, attempt_count, used_sentence_build
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
[
|
||||
entry['id'],
|
||||
entry['itemId'],
|
||||
@@ -350,6 +373,14 @@ class LocalSnapshotStore {
|
||||
entry['originalTranscript'],
|
||||
entry['transcriptConfirmed'] == true ? 1 : 0,
|
||||
entry['transcriptEdited'] == true ? 1 : 0,
|
||||
entry['listeningSupport'],
|
||||
entry['firstTry'] == null
|
||||
? null
|
||||
: entry['firstTry'] == true
|
||||
? 1
|
||||
: 0,
|
||||
entry['attemptCount'],
|
||||
entry['usedSentenceBuild'] == true ? 1 : 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -435,6 +466,12 @@ class LocalSnapshotStore {
|
||||
'completedSegmentIds',
|
||||
'activeSegmentIndexes',
|
||||
'lessonListeningComplete',
|
||||
'listeningAudioPlayed',
|
||||
'listeningAudioUnavailable',
|
||||
'listeningSupport',
|
||||
'listeningAttempts',
|
||||
'sentenceBuildUsed',
|
||||
'sentenceBuildIndex',
|
||||
'lessonSpeakingComplete',
|
||||
'lessonReadingComplete',
|
||||
'lessonWritingComplete',
|
||||
|
||||
@@ -92,6 +92,8 @@ enum EvidenceKind {
|
||||
pending,
|
||||
}
|
||||
|
||||
enum ListeningSupport { none, slow, transcript }
|
||||
|
||||
/// Local dialogue matching is intentionally tri-state. A natural English
|
||||
/// reply that does not match a strict taught-language rule is unknown, not
|
||||
/// automatically wrong.
|
||||
@@ -160,6 +162,10 @@ class AttemptEvidence {
|
||||
this.originalTranscript,
|
||||
this.transcriptConfirmed = false,
|
||||
this.transcriptEdited = false,
|
||||
this.listeningSupport,
|
||||
this.firstTry,
|
||||
this.attemptCount,
|
||||
this.usedSentenceBuild = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -182,6 +188,10 @@ class AttemptEvidence {
|
||||
final String? originalTranscript;
|
||||
final bool transcriptConfirmed;
|
||||
final bool transcriptEdited;
|
||||
final ListeningSupport? listeningSupport;
|
||||
final bool? firstTry;
|
||||
final int? attemptCount;
|
||||
final bool usedSentenceBuild;
|
||||
}
|
||||
|
||||
enum AiProviderType { mock, gemini, openAi, compatible }
|
||||
@@ -212,8 +222,9 @@ class DialogueAiResponse {
|
||||
final String? feedback;
|
||||
}
|
||||
|
||||
/// A bounded suggestion only. It never changes completion, mastery, or a
|
||||
/// stage result; those decisions remain on-device and evidence-based.
|
||||
/// A bounded evaluation input. Lesson free-response evidence requires both
|
||||
/// local task matching and an accepted verdict; mastery and stage decisions
|
||||
/// still remain on-device and derive from recorded attempts.
|
||||
class WritingAiFeedback {
|
||||
const WritingAiFeedback({
|
||||
required this.verdict,
|
||||
|
||||
@@ -77,7 +77,7 @@ class VoiceService {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> speak(String text, {bool slow = false}) async {
|
||||
Future<bool> speak(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await stopRecordingPlayback();
|
||||
await _initTts();
|
||||
@@ -95,8 +95,11 @@ class VoiceService {
|
||||
} catch (_) {}
|
||||
}
|
||||
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
||||
await _tts.speak(text);
|
||||
} catch (_) {}
|
||||
final result = await _tts.speak(text);
|
||||
return result == 1 || result == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopSpeaking() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// 课程路径:全部课程按顺序排列,显示完成、当前与锁定状态。
|
||||
@@ -17,7 +18,10 @@ class LessonPath extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = allLessons
|
||||
final available = allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.toList();
|
||||
final done = available
|
||||
.where((lesson) => state.completedLessonIds.contains(lesson.id))
|
||||
.length;
|
||||
return SpacedColumn(
|
||||
@@ -32,7 +36,7 @@ class LessonPath extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$done/${allLessons.length} 完成',
|
||||
'$done/${available.length} 已开放课程完成',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -66,7 +70,8 @@ class _LessonTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final completed = state.completedLessonIds.contains(lesson.id);
|
||||
final current = lesson.id == state.activeLessonId && !completed;
|
||||
final draft = lesson.status != ContentStatus.approved;
|
||||
final current = lesson.id == state.activeLessonId && !completed && !draft;
|
||||
final open =
|
||||
state.isLessonUnlocked(lesson.id) &&
|
||||
(!state.reviewBacklog || completed);
|
||||
@@ -98,7 +103,9 @@ class _LessonTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
segments > 1
|
||||
draft
|
||||
? '待教学审校后开放'
|
||||
: segments > 1
|
||||
? '${lesson.outcome} · 小段 $segmentsDone/$segments'
|
||||
: lesson.outcome,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// 今天最该做的一件事:继续课程对话 → 到期复习 → 阶段评估/巩固 → 下一课。
|
||||
@@ -50,7 +51,8 @@ class TodayTaskCard extends StatelessWidget {
|
||||
|
||||
_TodayTask _todayTask() {
|
||||
var lessonId = state.activeLessonId;
|
||||
if (state.completedLessonIds.contains(lessonId)) {
|
||||
if (state.completedLessonIds.contains(lessonId) ||
|
||||
lessonById(lessonId).status != ContentStatus.approved) {
|
||||
final next = state.nextIncompleteLessonId;
|
||||
if (next != null) {
|
||||
lessonId = next;
|
||||
@@ -101,8 +103,8 @@ class TodayTaskCard extends StatelessWidget {
|
||||
if (state.allLessonsComplete) {
|
||||
return _TodayTask(
|
||||
label: '已学完',
|
||||
title: '所有课程已完成',
|
||||
note: '你已学完全部课程内容!建议保持日常到期复习或自由情境对话。',
|
||||
title: '当前开放课程已完成',
|
||||
note: '后续课程待教学审校开放。现在可以继续到期复习或自由情境对话。',
|
||||
action: '去复习',
|
||||
minutes: '10 分钟',
|
||||
onPressed: onStartReview,
|
||||
|
||||
@@ -44,11 +44,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
final independentController = TextEditingController();
|
||||
int selectedAnswer = -1;
|
||||
|
||||
/// Set once a wrong meaning is picked, so only a first-try choice counts as
|
||||
/// recognition evidence.
|
||||
bool listeningMissed = false;
|
||||
int previewIndex = 0;
|
||||
bool listeningAudioPlayed = false;
|
||||
bool showWritingHelp = true;
|
||||
bool showIndependentHelp = false;
|
||||
LessonSegment get segment {
|
||||
@@ -56,11 +52,9 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
|
||||
}
|
||||
|
||||
LessonActivity get activity =>
|
||||
activityBySegmentId(segment.id);
|
||||
LessonActivity get activity => activityBySegmentId(segment.id);
|
||||
|
||||
List<VocabularyItem> get previewItems =>
|
||||
vocabularyBySegmentId(segment.id);
|
||||
List<VocabularyItem> get previewItems => vocabularyBySegmentId(segment.id);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -122,17 +116,21 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
options: listeningOptions,
|
||||
correctAnswer: activity.answers.first,
|
||||
selectedAnswer: selectedAnswer,
|
||||
audioPlayed: listeningAudioPlayed,
|
||||
missed: listeningMissed,
|
||||
onSelected: (value) => setState(() {
|
||||
selectedAnswer = value;
|
||||
audioPlayed: widget.state.listeningAudioPlayed,
|
||||
audioUnavailable: widget.state.listeningAudioUnavailable,
|
||||
support: widget.state.listeningSupport,
|
||||
missed: widget.state.listeningAttempts > 0,
|
||||
onSelected: (value) {
|
||||
setState(() => selectedAnswer = value);
|
||||
if (listeningOptions[value] != activity.answers.first) {
|
||||
listeningMissed = true;
|
||||
widget.state.recordListeningMiss();
|
||||
}
|
||||
}),
|
||||
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
||||
},
|
||||
onPlayed: () => widget.state.recordListeningPlayback(),
|
||||
onAudioFailed: widget.state.recordListeningAudioUnavailable,
|
||||
onSlowPlayed: () => widget.state.recordListeningPlayback(slow: true),
|
||||
onLookup: () {
|
||||
setState(() => listeningMissed = true);
|
||||
widget.state.revealListeningTranscript();
|
||||
showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
@@ -142,8 +140,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
onContinue:
|
||||
selectedAnswer >= 0 &&
|
||||
listeningOptions[selectedAnswer] == activity.answers.first
|
||||
? () =>
|
||||
widget.state.completeListening(recognized: !listeningMissed)
|
||||
? () => widget.state.completeListening(recognized: true)
|
||||
: null,
|
||||
);
|
||||
case LessonStep.speaking:
|
||||
@@ -152,8 +149,13 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
segmentId: segment.id,
|
||||
text: activity.speaking,
|
||||
tip: activity.speakingTip,
|
||||
chunks: activity.speakingChunks,
|
||||
examples: activity.speakingExamples,
|
||||
buildIndex: widget.state.sentenceBuildIndex,
|
||||
onBuildProgress: widget.state.recordSentenceBuildProgress,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
onContinue: widget.state.completeSpeaking,
|
||||
onContinue: (practiced) =>
|
||||
widget.state.completeSpeaking(practiced: practiced),
|
||||
);
|
||||
case LessonStep.reading:
|
||||
content = _ReadingStep(
|
||||
@@ -191,8 +193,11 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
},
|
||||
onToggleHelp: () =>
|
||||
setState(() => showWritingHelp = !showWritingHelp),
|
||||
onContinue: (usedAiFeedback) => widget.state.completeWriting(
|
||||
assisted: showWritingHelp || usedAiFeedback,
|
||||
onContinue: (verified) => widget.state.completeWriting(
|
||||
// The example is visible when this guided step opens. Hiding it
|
||||
// later cannot turn the same answer into an independent attempt.
|
||||
assisted: true,
|
||||
verified: verified,
|
||||
rawAnswer: writingController.text.trim(),
|
||||
),
|
||||
);
|
||||
@@ -221,9 +226,10 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
initialText: activity.independentPrompt,
|
||||
);
|
||||
},
|
||||
onContinue: (spoken, recordingPath, aiCorrected) =>
|
||||
onContinue: (spoken, recordingPath, aiCorrected, verified) =>
|
||||
widget.state.completeIndependentAttempt(
|
||||
assisted: showIndependentHelp || aiCorrected,
|
||||
verified: verified,
|
||||
spoken: spoken,
|
||||
rawAnswer: independentController.text.trim(),
|
||||
recordingPath: recordingPath,
|
||||
@@ -234,6 +240,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
case LessonStep.complete:
|
||||
content = _CompletionStep(
|
||||
assisted: widget.state.independentAttemptAssisted,
|
||||
pending: widget.state.independentAttemptPending,
|
||||
isFinalSegment:
|
||||
widget.state.activeSegmentIndexFor(lesson.id) ==
|
||||
lesson.segments.length - 1,
|
||||
@@ -343,11 +350,13 @@ class _DialoguePendingStep extends StatelessWidget {
|
||||
class _CompletionStep extends StatelessWidget {
|
||||
const _CompletionStep({
|
||||
required this.assisted,
|
||||
required this.pending,
|
||||
required this.isFinalSegment,
|
||||
required this.nextSegmentNumber,
|
||||
required this.onFinish,
|
||||
});
|
||||
final bool assisted;
|
||||
final bool pending;
|
||||
final bool isFinalSegment;
|
||||
final int nextSegmentNumber;
|
||||
final VoidCallback onFinish;
|
||||
@@ -362,7 +371,13 @@ class _CompletionStep extends StatelessWidget {
|
||||
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
|
||||
Text(
|
||||
pending
|
||||
? '练习已保存,但答案未经 AI 核验,不计入独立掌握。后续会安排复练。'
|
||||
: assisted
|
||||
? '独立尝试使用了帮助,系统会安排不同题再练一次。'
|
||||
: '这次独立尝试已记录,后续会在不同情境中复练。',
|
||||
),
|
||||
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
|
||||
PrimaryButton(label: '回到首页', onPressed: onFinish),
|
||||
],
|
||||
@@ -371,20 +386,28 @@ class _CompletionStep extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AudioRow extends StatelessWidget {
|
||||
const _AudioRow({required this.label, this.speech, this.onPlayed});
|
||||
const _AudioRow({
|
||||
required this.label,
|
||||
this.speech,
|
||||
this.onPlayed,
|
||||
this.onFailed,
|
||||
this.showSlow = true,
|
||||
});
|
||||
final String label;
|
||||
final String? speech;
|
||||
final VoidCallback? onPlayed;
|
||||
final VoidCallback? onFailed;
|
||||
final bool showSlow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label);
|
||||
} finally {
|
||||
if (await VoiceService.instance.speak(speech ?? label)) {
|
||||
onPlayed?.call();
|
||||
} else {
|
||||
onFailed?.call();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
@@ -393,10 +416,10 @@ class _AudioRow extends StatelessWidget {
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label);
|
||||
} finally {
|
||||
if (await VoiceService.instance.speak(speech ?? label)) {
|
||||
onPlayed?.call();
|
||||
} else {
|
||||
onFailed?.call();
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
@@ -405,16 +428,18 @@ class _AudioRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label, slow: true);
|
||||
} finally {
|
||||
onPlayed?.call();
|
||||
}
|
||||
},
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
if (showSlow)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await VoiceService.instance.speak(
|
||||
speech ?? label,
|
||||
slow: true,
|
||||
)) {
|
||||
onPlayed?.call();
|
||||
}
|
||||
},
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ class _IndependentStep extends StatefulWidget {
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onNeedHelp;
|
||||
final VoidCallback onLookup;
|
||||
final void Function(bool spoken, String? recordingPath, bool aiCorrected)
|
||||
final void Function(
|
||||
bool spoken,
|
||||
String? recordingPath,
|
||||
bool aiCorrected,
|
||||
bool verified,
|
||||
)
|
||||
onContinue;
|
||||
final VoidCallback onLater;
|
||||
|
||||
@@ -57,7 +62,8 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Future<void> _submit() async {
|
||||
if (!widget.canContinue || checkingWithAi) return;
|
||||
final shortfall = independentShortfall(
|
||||
widget.segmentId,
|
||||
widget.controller.text,
|
||||
@@ -66,12 +72,20 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
setState(() => validationError = shortfall);
|
||||
return;
|
||||
}
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
_finish(verified: false);
|
||||
return;
|
||||
}
|
||||
await _checkWithAi();
|
||||
if (!mounted) return;
|
||||
if (aiCheck?.verdict == 'accepted') _finish(verified: true);
|
||||
}
|
||||
|
||||
void _finish({required bool verified}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
aiCheck?.verdict == 'accepted'
|
||||
? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。'
|
||||
: '通过:用上了本段要练的表达。',
|
||||
verified ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '练习已保存;答案未经核验,不计入独立掌握。',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -79,6 +93,7 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
usedVoice && !transcriptEdited,
|
||||
widget.keepRecording ? recordingPath : null,
|
||||
aiCorrected,
|
||||
verified,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,13 +105,9 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
|
||||
Future<void> _checkWithAi() async {
|
||||
final answer = widget.controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
@@ -119,9 +130,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
||||
? '暂时无法核验答案。可重试,或仅保存练习继续。'
|
||||
: feedback.verdict == 'uncertain'
|
||||
? 'AI 无法确定答案是否正确。可重试,或仅保存练习继续。'
|
||||
: null;
|
||||
if (feedback != null && feedback.verdict != 'accepted') {
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
aiCorrected = true;
|
||||
}
|
||||
});
|
||||
@@ -191,6 +204,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||||
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(widget.activity.independentPrompt),
|
||||
if (widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
@@ -255,19 +273,6 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.canContinue && !checkingWithAi
|
||||
? _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(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
@@ -282,8 +287,10 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
||||
: '看过改正后再提交,本次会记为带提示完成。',
|
||||
? '本地目标与 AI 检查均通过。'
|
||||
: aiCheck!.verdict == 'rewrite'
|
||||
? '修改后再次提交;看过改正,本次会记为辅助练习。'
|
||||
: '答案尚未核验,可重试或仅保存练习。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -322,9 +329,14 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
else
|
||||
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||||
PrimaryButton(
|
||||
label: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成',
|
||||
onPressed: widget.canContinue ? _submit : null,
|
||||
label: checkingWithAi ? '正在自动检查…' : '提交独立尝试',
|
||||
onPressed: widget.canContinue && !checkingWithAi ? _submit : null,
|
||||
),
|
||||
if (aiCheckError != null)
|
||||
TextButton(
|
||||
onPressed: () => _finish(verified: false),
|
||||
child: const Text('未核验,保存练习并继续'),
|
||||
),
|
||||
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,9 +8,13 @@ class _ListeningStep extends StatelessWidget {
|
||||
required this.correctAnswer,
|
||||
required this.selectedAnswer,
|
||||
required this.audioPlayed,
|
||||
required this.audioUnavailable,
|
||||
required this.support,
|
||||
required this.missed,
|
||||
required this.onSelected,
|
||||
required this.onPlayed,
|
||||
required this.onAudioFailed,
|
||||
required this.onSlowPlayed,
|
||||
required this.onLookup,
|
||||
required this.onContinue,
|
||||
});
|
||||
@@ -22,11 +26,15 @@ class _ListeningStep extends StatelessWidget {
|
||||
|
||||
final int selectedAnswer;
|
||||
final bool audioPlayed;
|
||||
final bool audioUnavailable;
|
||||
final ListeningSupport support;
|
||||
|
||||
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
|
||||
final bool missed;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onPlayed;
|
||||
final VoidCallback onAudioFailed;
|
||||
final VoidCallback onSlowPlayed;
|
||||
final VoidCallback onLookup;
|
||||
final VoidCallback? onContinue;
|
||||
|
||||
@@ -35,7 +43,8 @@ class _ListeningStep extends StatelessWidget {
|
||||
final answers = options;
|
||||
final answeredCorrectly =
|
||||
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
|
||||
final revealText = answeredCorrectly || missed;
|
||||
final revealText =
|
||||
answeredCorrectly || support == ListeningSupport.transcript;
|
||||
return _LessonScaffold(
|
||||
step: 2,
|
||||
child: SpacedColumn(
|
||||
@@ -52,13 +61,32 @@ class _ListeningStep extends StatelessWidget {
|
||||
label: audioPlayed ? '再播放一次' : '播放问题',
|
||||
speech: activity.listening,
|
||||
onPlayed: onPlayed,
|
||||
onFailed: onAudioFailed,
|
||||
showSlow: false,
|
||||
),
|
||||
),
|
||||
if (!audioPlayed)
|
||||
if (audioPlayed && !revealText)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final played = await VoiceService.instance.speak(
|
||||
activity.listening,
|
||||
slow: true,
|
||||
);
|
||||
if (played) onSlowPlayed();
|
||||
},
|
||||
icon: const Icon(Icons.slow_motion_video),
|
||||
label: Text(support == ListeningSupport.slow ? '再慢听一次' : '慢一点再听'),
|
||||
),
|
||||
if (!audioPlayed && !audioUnavailable)
|
||||
Text(
|
||||
'先播放音频,听完再选择。原文会在作答后显示。',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
if (audioUnavailable)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('当前设备无法播放语音。下面显示原文,你仍可完成理解练习;本题不会计为独立听辨。'),
|
||||
),
|
||||
if (revealText)
|
||||
SectionCard(child: LexiconText(activity.listening, state: state))
|
||||
else if (audioPlayed)
|
||||
@@ -70,7 +98,9 @@ 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: audioPlayed || audioUnavailable
|
||||
? () => onSelected(index)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -84,7 +114,7 @@ class _ListeningStep extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
answers[index],
|
||||
style: audioPlayed
|
||||
style: audioPlayed || audioUnavailable
|
||||
? null
|
||||
: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
@@ -94,14 +124,11 @@ class _ListeningStep extends StatelessWidget {
|
||||
PrimaryButton(
|
||||
label: selectedAnswer >= 0
|
||||
? '检查并继续'
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频'),
|
||||
: (audioPlayed || audioUnavailable ? '请选择答案' : '先播放音频'),
|
||||
onPressed: onContinue,
|
||||
),
|
||||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||
Text(
|
||||
'再听一次,选择正确答案。',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
Text('再听一次,选择正确答案。', style: TextStyle(color: AppColors.warmInk)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,15 +6,23 @@ class _SpeakingStep extends StatefulWidget {
|
||||
required this.segmentId,
|
||||
required this.text,
|
||||
this.tip = '',
|
||||
this.chunks = const [],
|
||||
this.examples = const [],
|
||||
required this.buildIndex,
|
||||
required this.onBuildProgress,
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String segmentId;
|
||||
final String text;
|
||||
final String tip;
|
||||
final List<String> chunks;
|
||||
final List<String> examples;
|
||||
final int buildIndex;
|
||||
final ValueChanged<int> onBuildProgress;
|
||||
final AppState state;
|
||||
final bool keepRecording;
|
||||
final VoidCallback onContinue;
|
||||
final ValueChanged<bool> onContinue;
|
||||
|
||||
@override
|
||||
State<_SpeakingStep> createState() => _SpeakingStepState();
|
||||
@@ -89,12 +97,57 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
if (widget.tip.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
widget.tip,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
child: Text(widget.tip, style: TextStyle(color: AppColors.warmInk)),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
if (widget.chunks.length >= 2)
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('拆开练(可选)'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.buildIndex == 0
|
||||
? '先听词块,再逐步合成整句。'
|
||||
: widget.chunks.take(widget.buildIndex).join(' '),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
final next = widget.buildIndex < widget.chunks.length
|
||||
? widget.buildIndex + 1
|
||||
: 1;
|
||||
await VoiceService.instance.speak(
|
||||
widget.chunks.take(next).join(' '),
|
||||
);
|
||||
widget.onBuildProgress(next);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(
|
||||
widget.buildIndex == widget.chunks.length
|
||||
? '从头再练'
|
||||
: '听下一段并跟读',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.examples.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: Text('更多示范句(${widget.examples.length})'),
|
||||
children: [
|
||||
for (final example in widget.examples)
|
||||
ListTile(
|
||||
title: LexiconText(example, state: widget.state),
|
||||
trailing: IconButton(
|
||||
tooltip: '播放示范句',
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
onPressed: () => VoiceService.instance.speak(example),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
@@ -146,8 +199,10 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
||||
onPressed: widget.onContinue,
|
||||
label: recordingPath == null ? '跳过跟读,继续' : '录音完成,继续',
|
||||
onPressed: (listening || transcribing)
|
||||
? null
|
||||
: () => widget.onContinue(recordingPath != null),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -34,23 +34,18 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
String? aiFeedbackError;
|
||||
bool requestingAiFeedback = false;
|
||||
|
||||
void _checkOrContinue() {
|
||||
if (result?.complete == true) {
|
||||
widget.onContinue(aiFeedback != null);
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
() => result = WritingFeedback.check(
|
||||
widget.segmentId,
|
||||
widget.controller.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAiFeedback() async {
|
||||
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
||||
Future<void> _submit() async {
|
||||
if (!widget.canContinue || requestingAiFeedback) return;
|
||||
final answer = widget.controller.text.trim();
|
||||
final local = WritingFeedback.check(widget.segmentId, answer);
|
||||
setState(() {
|
||||
result = local;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
if (!local.complete) return;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
||||
widget.onContinue(false);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -64,19 +59,28 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
model: widget.state.aiModel,
|
||||
lessonId: widget.lessonId,
|
||||
taskPrompt: widget.activity.writingPrompt,
|
||||
answer: widget.controller.text.trim(),
|
||||
answer: answer,
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!mounted || widget.controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => requestingAiFeedback = false);
|
||||
return;
|
||||
}
|
||||
if (feedback?.verdict == 'accepted') {
|
||||
widget.onContinue(true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
requestingAiFeedback = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
||||
aiFeedbackError = feedback == null || feedback.verdict == 'uncertain'
|
||||
? '答案暂时无法核验。可以重试,或仅保存这次练习继续学习。'
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
void _continueUnverified() => widget.onContinue(false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _LessonScaffold(
|
||||
step: 5,
|
||||
@@ -87,6 +91,11 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
widget.activity.writingPrompt,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||
@@ -126,19 +135,6 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
onPressed: widget.onToggleHelp,
|
||||
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.canContinue && !requestingAiFeedback
|
||||
? _requestAiFeedback
|
||||
: null,
|
||||
icon: requestingAiFeedback
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(requestingAiFeedback ? '正在获取反馈…' : '获取 AI 写作建议(可选)'),
|
||||
),
|
||||
if (aiFeedback != null)
|
||||
SectionCard(
|
||||
tint: aiFeedback!.verdict == 'accepted'
|
||||
@@ -153,7 +149,9 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
if (aiFeedback!.suggestion != null)
|
||||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||
Text(
|
||||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
||||
aiFeedback!.verdict == 'rewrite'
|
||||
? '请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'
|
||||
: '答案尚未核验;可以重试检查或仅保存练习。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
@@ -167,20 +165,25 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (result != null)
|
||||
if (result != null && !result!.complete)
|
||||
SectionCard(
|
||||
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
result!.message,
|
||||
style: TextStyle(
|
||||
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
||||
),
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
||||
onPressed: widget.canContinue ? _checkOrContinue : null,
|
||||
label: requestingAiFeedback ? '正在自动检查…' : '提交并继续',
|
||||
onPressed: widget.canContinue && !requestingAiFeedback
|
||||
? _submit
|
||||
: null,
|
||||
),
|
||||
if (aiFeedbackError != null)
|
||||
TextButton(
|
||||
onPressed: _continueUnverified,
|
||||
child: const Text('未核验,保存练习并继续'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -147,13 +147,21 @@ class _PlacementPageState extends State<PlacementPage>
|
||||
bool get _answeredName =>
|
||||
coreItemUsedIn('A0-P01', answerController.text.trim());
|
||||
|
||||
/// Placement only picks an A0 starting point until A1 content is frozen, and
|
||||
/// it never marks a lesson as passed.
|
||||
String get _suggestedLessonId =>
|
||||
_understoodGreeting && _answeredName ? 'a0-02' : 'a0-01';
|
||||
/// Self report only moves the starting point when both short checks pass.
|
||||
/// Placement never marks the earlier lessons as completed.
|
||||
String get _suggestedLessonId {
|
||||
if (!_understoodGreeting || !_answeredName) return 'a0-01';
|
||||
return switch (selected) {
|
||||
PlacementLevel.beginner => 'a0-02',
|
||||
PlacementLevel.someBasics => 'a0-03',
|
||||
PlacementLevel.simpleConversation => 'a0-04',
|
||||
};
|
||||
}
|
||||
|
||||
String get _reason => _understoodGreeting && _answeredName
|
||||
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
||||
? selected == PlacementLevel.beginner
|
||||
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
||||
: '你通过了两项基础检查,结合自选基础,建议从这课试学;前面的课随时可以返回。'
|
||||
: _understoodGreeting
|
||||
? '你能听懂问候,先把自我介绍说完整会更稳。'
|
||||
: '先从问候和介绍名字开始,打好第一句的基础。';
|
||||
|
||||
@@ -30,6 +30,18 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = <String>{};
|
||||
final improved = <String, AttemptEvidence>{};
|
||||
for (final entry in widget.state.attemptEvidence) {
|
||||
final key = '${entry.itemId}|${entry.skill}';
|
||||
if (entry.outcome == EvidenceKind.assisted ||
|
||||
entry.outcome == EvidenceKind.languageError) {
|
||||
progress.add(key);
|
||||
} else if (entry.outcome == EvidenceKind.independentSuccess &&
|
||||
progress.contains(key)) {
|
||||
improved[key] = entry;
|
||||
}
|
||||
}
|
||||
final records = widget.state.attemptEvidence.reversed
|
||||
.where(_matches)
|
||||
.take(_limit)
|
||||
@@ -41,6 +53,21 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
||||
return SpacedColumn(
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (improved.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('从需要帮助到独立完成'),
|
||||
const SizedBox(height: 6),
|
||||
for (final entry in improved.values.toList().reversed.take(3))
|
||||
Text(
|
||||
'${widget.state.mastery[entry.itemId]?.label ?? entry.itemId} · ${entry.skill}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<_RecordFilter>(
|
||||
@@ -100,7 +127,7 @@ class _RecordTile extends StatelessWidget {
|
||||
),
|
||||
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
|
||||
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
|
||||
EvidenceKind.pending => ('稍后完成', AppColors.muted, AppColors.surfaceMuted),
|
||||
EvidenceKind.pending => ('待确认', AppColors.muted, AppColors.surfaceMuted),
|
||||
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
|
||||
};
|
||||
return ListTile(
|
||||
@@ -110,7 +137,19 @@ class _RecordTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text('${evidence.skill} · ${relativeTime(evidence.createdAt)}'),
|
||||
subtitle: Text(
|
||||
[
|
||||
evidence.skill,
|
||||
relativeTime(evidence.createdAt),
|
||||
if (evidence.listeningSupport == ListeningSupport.slow) '慢听后完成',
|
||||
if (evidence.listeningSupport == ListeningSupport.transcript)
|
||||
'看原文后完成',
|
||||
if (evidence.firstTry == true) '首次答对',
|
||||
if (evidence.attemptCount != null && evidence.attemptCount! > 1)
|
||||
'尝试 ${evidence.attemptCount} 次',
|
||||
if (evidence.usedSentenceBuild) '练过句子拆段',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -22,7 +22,7 @@ class LevelProgressView extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
state.a0Passed ? 'A1 主线内容尚未提供,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
||||
state.a0Passed ? 'A1 课程待教学审校开放,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
||||
),
|
||||
MenuGroup(
|
||||
children: [
|
||||
|
||||
@@ -306,7 +306,7 @@ class _StageCard extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
missing.isEmpty
|
||||
? 'A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'
|
||||
? 'A0 已通过。A1 课程待教学审校开放,可继续进行 A0 巩固。'
|
||||
: '还需:${missing.join(' · ')}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
final controller = TextEditingController();
|
||||
bool showHint = false;
|
||||
bool usedHelp = false;
|
||||
int answerChecks = 0;
|
||||
String? validationMessage;
|
||||
bool generatingVariant = false;
|
||||
bool generatingLesson = false;
|
||||
@@ -50,6 +51,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
/// The answer just accepted, shown before moving to the next item.
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
ListeningSupport dictationSupport = ListeningSupport.none;
|
||||
|
||||
/// Items whose recognition question has been answered in this session. The
|
||||
/// gate is asked once per item; the production task follows immediately.
|
||||
@@ -77,21 +79,23 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
answerChecks = 0;
|
||||
validationMessage = null;
|
||||
dictationDiff = null;
|
||||
transcript = null;
|
||||
recordingPath = null;
|
||||
dictationPlayed = false;
|
||||
dictationSupport = ListeningSupport.none;
|
||||
aiFeedback = null;
|
||||
aiFeedbackError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(ReviewItem item) async {
|
||||
Future<WritingAiFeedback?> _checkWithAi(ReviewItem item) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (answer.isEmpty || checkingWithAi) return null;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
|
||||
return;
|
||||
setState(() => aiFeedbackError = '未配置 AI 服务;本次只记为练习,不增加独立掌握。');
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
@@ -111,28 +115,46 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
// The learner may have moved on or kept typing while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiFeedback = feedback;
|
||||
aiFeedbackError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
||||
? '暂时无法获得 AI 检查结果;本次只记为练习,不增加独立掌握。'
|
||||
: null;
|
||||
// A shown correction is help: finishing afterwards counts as assisted.
|
||||
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
||||
});
|
||||
return feedback;
|
||||
}
|
||||
|
||||
void _next(ReviewItem item, {required bool assisted}) {
|
||||
Future<void> _next(ReviewItem item, {required bool assisted}) async {
|
||||
if (checkingWithAi) return;
|
||||
answerChecks++;
|
||||
final result = ReviewFeedback.check(item, controller.text);
|
||||
if (!result.complete) {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
validationMessage = result.message;
|
||||
dictationDiff = result.dictation;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item.skill != dictationSkill) {
|
||||
final answerBeforeCheck = controller.text.trim();
|
||||
final feedback = await _checkWithAi(item);
|
||||
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
validationMessage = '请根据 AI 反馈修改句子,再提交。';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feedback?.verdict != 'accepted') assisted = true;
|
||||
}
|
||||
assisted = assisted || usedHelp || answerChecks > 1;
|
||||
final answer = controller.text.trim();
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
@@ -142,6 +164,9 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
widget.state.completeReview(
|
||||
item,
|
||||
assisted: assisted,
|
||||
firstTry: answerChecks == 1,
|
||||
attemptCount: answerChecks,
|
||||
listeningSupport: dictation == null ? null : dictationSupport,
|
||||
rawAnswer: answer,
|
||||
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
|
||||
originalTranscript: transcript,
|
||||
@@ -221,7 +246,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
children: [
|
||||
Text(english, style: const TextStyle(fontSize: 22)),
|
||||
Row(
|
||||
children: [
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(audio),
|
||||
@@ -269,7 +294,11 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
// Being told the answer is the moment to say what the item is for. A
|
||||
// learner who could not produce `What's your name?` often knows the words
|
||||
// and not the situation.
|
||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
||||
if (coreContrastQuestion(
|
||||
item.id,
|
||||
isTaught: widget.state.mastery.containsKey,
|
||||
) !=
|
||||
null) {
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
contrastFor = item;
|
||||
@@ -298,14 +327,14 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('先弄清楚什么时候用'),
|
||||
Text(
|
||||
'这句话用在这种情况:',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text('这句话用在这种情况:', style: Theme.of(context).textTheme.headlineMedium),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
||||
child: Text(
|
||||
question.situation,
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
),
|
||||
const Text('这时候该说哪一句?'),
|
||||
for (final option in question.options)
|
||||
@@ -378,7 +407,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
result.assisted ? '带提示完成' : '答对了!',
|
||||
result.assisted ? '完成练习(未计独立掌握)' : '答对了!',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
@@ -439,11 +468,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
}
|
||||
|
||||
Future<void> _play(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await VoiceService.instance.speak(text, slow: slow);
|
||||
} finally {
|
||||
if (mounted) setState(() => dictationPlayed = true);
|
||||
}
|
||||
final played = await VoiceService.instance.speak(text, slow: slow);
|
||||
if (played && mounted) setState(() => dictationPlayed = true);
|
||||
}
|
||||
|
||||
Future<void> _generateVariant(ReviewItem item) async {
|
||||
@@ -629,7 +655,21 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
|
||||
TextButton(
|
||||
onPressed: () => _play(dictation.sentence, slow: true),
|
||||
onPressed: dictationPlayed
|
||||
? () async {
|
||||
final played = await VoiceService.instance
|
||||
.speak(dictation.sentence, slow: true);
|
||||
if (played && mounted) {
|
||||
setState(() {
|
||||
if (dictationSupport !=
|
||||
ListeningSupport.transcript) {
|
||||
dictationSupport = ListeningSupport.slow;
|
||||
}
|
||||
usedHelp = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
@@ -682,6 +722,9 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
onPressed: () => setState(() {
|
||||
showHint = true;
|
||||
usedHelp = true;
|
||||
if (dictation != null) {
|
||||
dictationSupport = ListeningSupport.transcript;
|
||||
}
|
||||
validationMessage = null;
|
||||
}),
|
||||
),
|
||||
@@ -729,6 +772,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
: '参考:${item.hint}\n目标:${item.target}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (dictation != null) Text('原文:${dictation.sentence}'),
|
||||
if (audioText != null && dictation == null)
|
||||
TextButton.icon(
|
||||
onPressed: () => _play(audioText),
|
||||
@@ -739,20 +783,12 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Dictation is already compared word by word against the sentence.
|
||||
if (dictation == null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _checkWithAi(item),
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
||||
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||
if (dictation == null &&
|
||||
widget.state.aiProvider == AiProviderType.mock)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: const Text('尚未配置 AI 服务。可继续复习,但开放回答不会计入独立掌握。'),
|
||||
),
|
||||
if (aiFeedback != null)
|
||||
SectionCard(
|
||||
@@ -768,7 +804,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
Text('参考改正:${aiFeedback!.suggestion}'),
|
||||
Text(
|
||||
aiFeedback!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
||||
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||
: '看过改正后再提交,本次会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -790,8 +826,8 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
),
|
||||
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
|
||||
PrimaryButton(
|
||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
label: usedHelp ? '带提示完成' : '检查并提交',
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _next(item, assisted: usedHelp),
|
||||
),
|
||||
@@ -872,6 +908,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
/// The answer passed the local check; the next tap records it.
|
||||
bool answerAccepted = false;
|
||||
bool checkingWithAi = false;
|
||||
bool aiVerified = false;
|
||||
WritingAiFeedback? aiCheck;
|
||||
String? aiCheckError;
|
||||
|
||||
@@ -925,7 +962,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
);
|
||||
}
|
||||
|
||||
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
|
||||
Future<void> _advance(
|
||||
GeneratedLesson lesson,
|
||||
GeneratedLessonTask task,
|
||||
) async {
|
||||
final shortfall = adaptiveAnswerShortfall(task, controller.text);
|
||||
final correct = shortfall == null;
|
||||
if (!showReference && !correct) {
|
||||
@@ -934,6 +974,18 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
}
|
||||
// Show the verdict before moving on, so the learner sees it.
|
||||
if (!answerAccepted) {
|
||||
if (correct &&
|
||||
!showReference &&
|
||||
(task.skill == 'writing' || task.skill == 'speaking')) {
|
||||
final answerBeforeCheck = controller.text.trim();
|
||||
final feedback = await _checkWithAi(task);
|
||||
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||
if (feedback?.verdict == 'rewrite') {
|
||||
setState(() => answerFeedback = '请根据 AI 反馈修改句子,再检查。');
|
||||
return;
|
||||
}
|
||||
aiVerified = feedback?.verdict == 'accepted';
|
||||
}
|
||||
setState(() {
|
||||
answerAccepted = true;
|
||||
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
|
||||
@@ -944,7 +996,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
lesson: lesson,
|
||||
task: task,
|
||||
rawAnswer: controller.text.trim(),
|
||||
assisted: showReference || aiCorrected,
|
||||
assisted:
|
||||
showReference ||
|
||||
aiCorrected ||
|
||||
((task.skill == 'writing' || task.skill == 'speaking') &&
|
||||
!aiVerified),
|
||||
correct: correct,
|
||||
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
||||
? 'speechToText'
|
||||
@@ -988,16 +1044,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
|
||||
void _clearChecks() {
|
||||
answerAccepted = false;
|
||||
aiVerified = false;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}
|
||||
|
||||
Future<void> _checkWithAi(GeneratedLessonTask task) async {
|
||||
Future<WritingAiFeedback?> _checkWithAi(GeneratedLessonTask task) async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi) return;
|
||||
if (answer.isEmpty || checkingWithAi) return null;
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
|
||||
return;
|
||||
setState(() => aiCheckError = '未配置 AI 服务;本次仅作教学练习。');
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
@@ -1016,18 +1073,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
// The learner may have kept typing or moved on while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
|
||||
: null;
|
||||
aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果;本次仅作教学练习。' : null;
|
||||
if (feedback != null && feedback.verdict != 'accepted') {
|
||||
aiCorrected = true;
|
||||
}
|
||||
});
|
||||
return feedback;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1248,20 +1304,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
if (task.skill == 'writing' || task.skill == 'speaking')
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||
? null
|
||||
: () => _checkWithAi(task),
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
||||
),
|
||||
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
@@ -1276,7 +1319,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;是否正确仍由本地检查判断。'
|
||||
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||
: '看过改正后再继续,本项会记为带提示完成。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
@@ -1304,7 +1347,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
Icon(Icons.check_circle, color: AppColors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
|
||||
showReference ||
|
||||
aiCorrected ||
|
||||
((task.skill == 'writing' ||
|
||||
task.skill == 'speaking') &&
|
||||
!aiVerified)
|
||||
? '完成练习(不计独立掌握)'
|
||||
: '答对了!',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1346,7 +1395,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
: index == lesson.tasks.length - 1
|
||||
? '完成补练'
|
||||
: '下一项',
|
||||
onPressed: controller.text.trim().isNotEmpty
|
||||
onPressed: controller.text.trim().isNotEmpty && !checkingWithAi
|
||||
? () => _advance(lesson, task)
|
||||
: null,
|
||||
),
|
||||
|
||||
@@ -175,42 +175,42 @@ class _LearningShellState extends State<LearningShell> {
|
||||
|
||||
return 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.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.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: "我的",
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
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.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.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: "我的",
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -223,8 +223,9 @@ class _LearningShellState extends State<LearningShell> {
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartLesson: () {
|
||||
if (widget.state.completedLessonIds
|
||||
.contains(widget.state.activeLessonId)) {
|
||||
if (widget.state.completedLessonIds.contains(
|
||||
widget.state.activeLessonId,
|
||||
)) {
|
||||
final next = widget.state.nextIncompleteLessonId;
|
||||
if (next != null) widget.state.openLesson(next);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,8 @@ class _WordsPageState extends State<WordsPage> {
|
||||
),
|
||||
// The sentence is the point of the feedback: a gloss alone is
|
||||
// what did not stick the first time.
|
||||
if (WordBank.instance.exampleOf(question.id) case final sample?) ...[
|
||||
if (WordBank.instance.exampleOf(question.id)
|
||||
case final sample?) ...[
|
||||
Text(
|
||||
sample.en,
|
||||
style: TextStyle(
|
||||
@@ -278,7 +279,8 @@ class _WordsPageState extends State<WordsPage> {
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in shaky.take(8)) _WordRow(id: id, state: state),
|
||||
for (final id in shaky.take(8))
|
||||
_WordRow(id: id, state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -334,7 +336,10 @@ class _WordRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
wordEnglish(id),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
wordIpa(id).isEmpty
|
||||
@@ -345,14 +350,11 @@ class _WordRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
},
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
}, style: Theme.of(context).textTheme.bodySmall),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
||||
|
||||
@@ -11,9 +11,7 @@ import 'features/shell/learning_shell.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
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());
|
||||
|
||||
@@ -37,7 +37,8 @@ class UsageCard extends StatelessWidget {
|
||||
if (usage == null) return const SizedBox.shrink();
|
||||
final lines = <({String label, String text})>[
|
||||
if (usage.when.isNotEmpty) (label: '什么时候用', text: usage.when),
|
||||
if (!compact && usage.reply.isNotEmpty) (label: '对方会怎么答', text: usage.reply),
|
||||
if (!compact && usage.reply.isNotEmpty)
|
||||
(label: '对方会怎么答', text: usage.reply),
|
||||
if (!compact && usage.swap.isNotEmpty) (label: '换个说法', text: usage.swap),
|
||||
// Mid-exercise only the nearest confusion is worth a line; the full
|
||||
// list belongs on the preview and lookup cards.
|
||||
@@ -57,10 +58,9 @@ class UsageCard extends StatelessWidget {
|
||||
for (final line in lines)
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: AppColors.warmInk,
|
||||
height: 1.45,
|
||||
),
|
||||
style: DefaultTextStyle.of(
|
||||
context,
|
||||
).style.copyWith(color: AppColors.warmInk, height: 1.45),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${line.label}:',
|
||||
@@ -94,7 +94,10 @@ class SegmentUsage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ids = findLessonSegment(segmentId)?.targetItemIds ?? const <String>[];
|
||||
final explained = [for (final id in ids) if (coreUsage(id) != null) id];
|
||||
final explained = [
|
||||
for (final id in ids)
|
||||
if (coreUsage(id) != null) id,
|
||||
];
|
||||
if (explained.isEmpty) return const SizedBox.shrink();
|
||||
return SpacedColumn(
|
||||
spacing: 8,
|
||||
|
||||
Reference in New Issue
Block a user