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;
@@ -14,6 +14,11 @@ import 'sync_service.dart';
class SyncCoordinator extends ChangeNotifier {
static const _prefKey = 'sync_config_v1';
/// 合并逻辑版本。升级后丢弃旧的增量同步时间戳,强制全量拉取一次,
/// 让旧版本已经跳过的课程位置和复习卡片能被重新合并。
static const _mergeVersion = 2;
static const _mergeVersionKey = 'mergeVersion';
static final SyncCoordinator instance = SyncCoordinator._();
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
@@ -49,6 +54,9 @@ class SyncCoordinator extends ChangeNotifier {
if (raw != null && raw.isNotEmpty) {
final map = jsonDecode(raw) as Map<String, dynamic>;
_config = SyncConfig.fromJson(map);
if ((map[_mergeVersionKey] as int? ?? 1) < _mergeVersion) {
_config = _withoutLastSyncTime(_config);
}
}
} catch (e) {
debugPrint('[SyncCoordinator] init error: $e');
@@ -61,12 +69,23 @@ class SyncCoordinator extends ChangeNotifier {
Future<void> _saveConfig() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, jsonEncode(_config.toJson()));
await prefs.setString(
_prefKey,
jsonEncode({..._config.toJson(), _mergeVersionKey: _mergeVersion}),
);
} catch (e) {
debugPrint('[SyncCoordinator] save config error: $e');
}
}
static SyncConfig _withoutLastSyncTime(SyncConfig config) => SyncConfig(
serverUrl: config.serverUrl,
token: config.token,
username: config.username,
userId: config.userId,
autoSyncEnabled: config.autoSyncEnabled,
);
/// 更新服务器地址
Future<void> updateServerUrl(String newUrl) async {
_config = _config.copyWith(serverUrl: newUrl.trim());
@@ -98,7 +117,8 @@ class SyncCoordinator extends ChangeNotifier {
username: username,
password: password,
);
_config = _config.copyWith(
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
_config = _withoutLastSyncTime(_config).copyWith(
serverUrl: serverUrl.trim(),
token: auth.token,
username: auth.username,
@@ -132,7 +152,8 @@ class SyncCoordinator extends ChangeNotifier {
username: username,
password: password,
);
_config = _config.copyWith(
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
_config = _withoutLastSyncTime(_config).copyWith(
serverUrl: serverUrl.trim(),
token: auth.token,
username: auth.username,
+83 -32
View File
@@ -1,3 +1,4 @@
import '../a0_core.dart';
import '../models.dart';
import '../app_state.dart';
import 'sync_models.dart';
@@ -20,8 +21,11 @@ class SyncMerger {
final masteryUpdates = state.mastery.values.map((m) {
// 查找对应复习到期时间
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
final dueAt = review?.dueAt.toUtc().toIso8601String() ??
DateTime.now().toUtc().toIso8601String();
final dueAt = (review?.dueAt ??
m.firstTaughtAt?.add(_firstReviewDelay) ??
DateTime.now())
.toUtc()
.toIso8601String();
final attempts = review?.attempts ?? 0;
final successfulReviews = review?.successfulReviews ?? 0;
@@ -71,60 +75,60 @@ class SyncMerger {
static bool applyPullResponse(AppState state, SyncPullResponse response) {
var changed = false;
// 1. 合并课程关卡 (Union)
// 1. 合并课程关卡 (Union),学习位置只前进不后退
if (response.progress != null) {
final p = response.progress!;
for (final id in p.completedLessonIds) {
if (!state.completedLessonIds.contains(id)) {
state.completedLessonIds.add(id);
changed = true;
}
}
for (final sid in p.completedSegmentIds) {
if (!state.completedSegmentIds.contains(sid)) {
state.completedSegmentIds.add(sid);
changed = true;
}
}
if (state.completedLessonIds.length != state.completedLessons) {
state.completedLessons = state.completedLessonIds.length;
if (state.mergeSyncedLessonProgress(
completedLessons: p.completedLessonIds,
completedSegments: p.completedSegmentIds,
remoteActiveLessonId: p.activeLessonId,
)) {
changed = true;
}
}
// 2. 合并复习掌握项 (Max Checkpoint)
// 2. 合并复习掌握项 (Max Checkpoint;同 Checkpoint 时证据更多者更新)
for (final m in response.masteryUpdates) {
final local = state.mastery[m.itemId];
final status = _parseMasteryStatus(m.status);
final evidence = (m.payload['evidence'] as List? ?? [])
.whereType<String>()
.map(_parseEvidenceKind)
.toList();
final firstTaughtAt = m.payload['firstTaughtAt'] is String
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
: null;
final needsReview = m.payload['needsReview'] as bool?;
if (local == null) {
// 本地没有,直接添加
final status = _parseMasteryStatus(m.status);
final evidence = (m.payload['evidence'] as List? ?? [])
.whereType<String>()
.map(_parseEvidenceKind)
.toList();
state.mastery[m.itemId] = MasteryItem(
id: m.itemId,
label: m.payload['label'] as String? ?? m.itemId,
status: status,
evidence: evidence,
needsReview: m.payload['needsReview'] as bool? ?? false,
needsReview: needsReview ?? false,
checkpoint: m.checkpoint,
firstTaughtAt: m.payload['firstTaughtAt'] != null
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
: null,
firstTaughtAt: firstTaughtAt,
);
changed = true;
} else if (m.checkpoint > local.checkpoint) {
// 云端 Checkpoint 更高,升级本地状态
final status = _parseMasteryStatus(m.status);
} else if (m.checkpoint > local.checkpoint ||
(m.checkpoint == local.checkpoint &&
evidence.length > local.evidence.length)) {
// 云端进度更新(证据只追加不删除,条数更多说明学得更多)
state.mastery[m.itemId] = local.copyWith(
checkpoint: m.checkpoint,
status: status,
needsReview: m.payload['needsReview'] as bool? ?? local.needsReview,
evidence: evidence.length > local.evidence.length ? evidence : null,
needsReview: needsReview ?? local.needsReview,
firstTaughtAt: local.firstTaughtAt ?? firstTaughtAt,
);
changed = true;
} else if (local.firstTaughtAt == null && firstTaughtAt != null) {
state.mastery[m.itemId] = local.copyWith(firstTaughtAt: firstTaughtAt);
changed = true;
}
if (_mergeReviewItem(state, m, firstTaughtAt)) changed = true;
}
// 3. 合并用户偏好设置 (按需合并)
@@ -146,6 +150,53 @@ class SyncMerger {
return changed;
}
/// 与 AppState 引入新课目标时的首次复习间隔保持一致
static const _firstReviewDelay = Duration(days: 1);
/// 首页的到期复习数来自 reviewQueue,因此云端掌握项也要还原为复习卡片
static bool _mergeReviewItem(
AppState state,
SyncMasteryItemPayload m,
DateTime? firstTaughtAt,
) {
// 只有在课程里正式引入过、或复习过的项目才会有复习卡片
if (firstTaughtAt == null && m.attempts == 0) return false;
// 从未复习过的卡片到期时间固定为首次学习后一天;旧版本客户端会把
// 缺失卡片的 due_at 写成推送时刻,这里据此纠正
final dueAt = m.attempts == 0 && firstTaughtAt != null
? firstTaughtAt.add(_firstReviewDelay)
: DateTime.tryParse(m.dueAt);
if (dueAt == null) return false;
final index = state.reviewQueue.indexWhere((r) => r.id == m.itemId);
if (index < 0) {
final template = coreReviewTemplate(m.itemId);
state.reviewQueue.add(
ReviewItem(
id: m.itemId,
target: a0CoreItems[m.itemId] ?? m.itemId,
prompt: template.prompt,
hint: template.hint,
dueAt: dueAt.toLocal(),
skill: template.skill,
attempts: m.attempts,
successfulReviews: m.successfulReviews,
),
);
return true;
}
final local = state.reviewQueue[index];
if (m.attempts > local.attempts) {
state.reviewQueue[index] = local.copyWith(
dueAt: dueAt.toLocal(),
attempts: m.attempts,
successfulReviews: m.successfulReviews,
);
return true;
}
return false;
}
static MasteryStatus _parseMasteryStatus(String str) {
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
MasteryStatus.newItem;