fix: 登录同步后首页恢复课程位置与复习卡片

- 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课
- 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间
- 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间
- 登录/注册及合并逻辑升级后强制全量拉取一次

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 17:05:15 +09:00
co-authored by Claude Opus 5
parent 6710a80fa5
commit 8ed52312fb
4 changed files with 324 additions and 38 deletions
+67
View File
@@ -1081,6 +1081,73 @@ class AppState extends ChangeNotifier {
SyncCoordinator.instance.triggerBackgroundSync(this);
}
/// Merges lesson progress pulled from the sync server.
///
/// Completed lessons/segments are unioned, and the learner's position only
/// ever moves forward: a device that has not caught up yet must never drag
/// another device back to an earlier lesson or segment.
bool mergeSyncedLessonProgress({
required Iterable<String> completedLessons,
required Iterable<String> completedSegments,
required String remoteActiveLessonId,
}) {
var changed = false;
final completedBefore = completedLessonIds.toSet();
for (final id in completedLessons) {
if (completedLessonIds.add(id)) changed = true;
}
for (final id in completedSegments) {
if (completedSegmentIds.add(id)) changed = true;
}
if (this.completedLessons != completedLessonIds.length) {
this.completedLessons = completedLessonIds.length;
changed = true;
}
int lessonIndex(String id) =>
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
var targetIndex = lessonIndex(activeLessonId);
final remoteIndex = lessonIndex(remoteActiveLessonId);
if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) {
targetIndex = remoteIndex;
}
// The server's active lesson can itself be stale. Skip lessons that only
// became complete through this pull, but leave a lesson alone when the
// learner deliberately reopened it locally after finishing it.
while (targetIndex >= 0 &&
targetIndex < a0SeedLessons.length - 1 &&
!completedBefore.contains(a0SeedLessons[targetIndex].id) &&
completedLessonIds.contains(a0SeedLessons[targetIndex].id)) {
targetIndex++;
}
var positionChanged = false;
if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) {
activeLessonId = a0SeedLessons[targetIndex].id;
positionChanged = true;
}
for (final lesson in a0SeedLessons) {
final segments = lesson.segments;
var firstOpen = segments.indexWhere(
(segment) => !completedSegmentIds.contains(segment.id),
);
if (firstOpen < 0) firstOpen = segments.length - 1;
if (firstOpen > activeSegmentIndexFor(lesson.id)) {
activeSegmentIndexes[lesson.id] = firstOpen;
if (lesson.id == activeLessonId) positionChanged = true;
changed = true;
}
}
if (positionChanged) {
_resetLessonFlow();
changed = true;
}
return changed;
}
void _resetLessonFlow() {
lessonStep = LessonStep.preview;
previewIndex = 0;