整个课程和数据UI重新设计版本
This commit is contained in:
@@ -20,7 +20,8 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
static const _mergeVersionKey = 'mergeVersion';
|
||||
|
||||
static final SyncCoordinator instance = SyncCoordinator._();
|
||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||
SyncCoordinator._({SyncService? service})
|
||||
: _service = service ?? SyncService();
|
||||
|
||||
@visibleForTesting
|
||||
factory SyncCoordinator.createForTesting({SyncService? service}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../a0_core.dart';
|
||||
import '../courses/courses.dart';
|
||||
import '../models.dart';
|
||||
import '../app_state.dart';
|
||||
import 'sync_models.dart';
|
||||
@@ -6,7 +6,10 @@ import 'sync_models.dart';
|
||||
/// 负责本地 AppState 与云端 DTO 之间的序列化与智能合并
|
||||
class SyncMerger {
|
||||
/// 将本地 AppState 打包为增量推送请求
|
||||
static SyncPushRequest buildPushRequest(AppState state, {String? deviceName}) {
|
||||
static SyncPushRequest buildPushRequest(
|
||||
AppState state, {
|
||||
String? deviceName,
|
||||
}) {
|
||||
final nowIso = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
final progressPayload = SyncProgressPayload(
|
||||
@@ -21,11 +24,12 @@ class SyncMerger {
|
||||
final masteryUpdates = state.mastery.values.map((m) {
|
||||
// 查找对应复习到期时间
|
||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||
final dueAt = (review?.dueAt ??
|
||||
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||
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;
|
||||
|
||||
@@ -56,9 +60,7 @@ class SyncMerger {
|
||||
aiEndpoint: state.aiEndpoint,
|
||||
aiModel: state.aiModel,
|
||||
aiProvider: state.aiProvider.name,
|
||||
settingsPayload: {
|
||||
'keepRecordings': state.keepRecordings,
|
||||
},
|
||||
settingsPayload: {'keepRecordings': state.keepRecordings},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
@@ -174,7 +176,7 @@ class SyncMerger {
|
||||
state.reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: m.itemId,
|
||||
target: a0CoreItems[m.itemId] ?? m.itemId,
|
||||
target: coreItemLabel(m.itemId),
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: dueAt.toLocal(),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
|
||||
/// 同步状态枚举
|
||||
enum SyncState {
|
||||
idle,
|
||||
syncing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
enum SyncState { idle, syncing, success, error }
|
||||
|
||||
/// 客户端同步配置与认证状态
|
||||
class SyncConfig {
|
||||
@@ -53,7 +47,8 @@ class SyncConfig {
|
||||
};
|
||||
|
||||
factory SyncConfig.fromJson(Map<String, dynamic> json) => SyncConfig(
|
||||
serverUrl: json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
serverUrl:
|
||||
json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
token: json['token'] as String?,
|
||||
username: json['username'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
@@ -78,12 +73,13 @@ class SyncAuthResponse {
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) => SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
expiresIn: json['expires_in'] as int? ?? 0,
|
||||
);
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) =>
|
||||
SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
expiresIn: json['expires_in'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 课程关卡进度 DTO
|
||||
@@ -113,18 +109,21 @@ class SyncProgressPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) => SyncProgressPayload(
|
||||
activeLessonId: json['active_lesson_id'] as String? ?? 'a0-01',
|
||||
completedLessonIds: (json['completed_lesson_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
activeStep: json['active_step'] as String? ?? 'preview',
|
||||
streakDays: json['streak_days'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncProgressPayload(
|
||||
activeLessonId: json['active_lesson_id'] as String? ?? 'a0-01',
|
||||
completedLessonIds: (json['completed_lesson_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
activeStep: json['active_step'] as String? ?? 'preview',
|
||||
streakDays: json['streak_days'] as int? ?? 0,
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 艾宾浩斯与掌握度 DTO
|
||||
@@ -160,18 +159,23 @@ class SyncMasteryItemPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) => SyncMasteryItemPayload(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
checkpoint: json['checkpoint'] as int? ?? 0,
|
||||
status: json['status'] as String? ?? 'learning',
|
||||
dueAt: json['due_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
successfulReviews: json['successful_reviews'] as int? ?? 0,
|
||||
attempts: json['attempts'] as int? ?? 0,
|
||||
payload: json['payload'] is Map<String, dynamic>
|
||||
? json['payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncMasteryItemPayload(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
checkpoint: json['checkpoint'] as int? ?? 0,
|
||||
status: json['status'] as String? ?? 'learning',
|
||||
dueAt:
|
||||
json['due_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
successfulReviews: json['successful_reviews'] as int? ?? 0,
|
||||
attempts: json['attempts'] as int? ?? 0,
|
||||
payload: json['payload'] is Map<String, dynamic>
|
||||
? json['payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户画像与偏好 DTO
|
||||
@@ -213,20 +217,23 @@ class SyncProfilePayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) => SyncProfilePayload(
|
||||
onboardingComplete: json['onboarding_complete'] as bool? ?? true,
|
||||
goal: json['goal'] as String? ?? 'travel',
|
||||
placement: json['placement'] as String? ?? 'A0',
|
||||
dailyMinutes: json['daily_minutes'] as int? ?? 20,
|
||||
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
|
||||
aiEndpoint: json['ai_endpoint'] as String? ?? '',
|
||||
aiModel: json['ai_model'] as String? ?? '',
|
||||
aiProvider: json['ai_provider'] as String? ?? '',
|
||||
settingsPayload: json['settings_payload'] is Map<String, dynamic>
|
||||
? json['settings_payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncProfilePayload(
|
||||
onboardingComplete: json['onboarding_complete'] as bool? ?? true,
|
||||
goal: json['goal'] as String? ?? 'travel',
|
||||
placement: json['placement'] as String? ?? 'A0',
|
||||
dailyMinutes: json['daily_minutes'] as int? ?? 20,
|
||||
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
|
||||
aiEndpoint: json['ai_endpoint'] as String? ?? '',
|
||||
aiModel: json['ai_model'] as String? ?? '',
|
||||
aiProvider: json['ai_provider'] as String? ?? '',
|
||||
settingsPayload: json['settings_payload'] is Map<String, dynamic>
|
||||
? json['settings_payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 增量推送请求
|
||||
@@ -273,9 +280,13 @@ class SyncPullResponse {
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return SyncPullResponse(
|
||||
serverTime: data['server_time'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
serverTime:
|
||||
data['server_time'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
progress: data['progress'] != null
|
||||
? SyncProgressPayload.fromJson(data['progress'] as Map<String, dynamic>)
|
||||
? SyncProgressPayload.fromJson(
|
||||
data['progress'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
masteryUpdates: (data['mastery_updates'] as List? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
|
||||
@@ -41,22 +41,26 @@ class SyncService {
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/register');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,22 +73,26 @@ class SyncService {
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/login');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,23 +105,28 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
var urlStr = '$base/api/v1/sync/pull';
|
||||
if (since != null) {
|
||||
urlStr += '?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
|
||||
urlStr +=
|
||||
'?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
|
||||
}
|
||||
final uri = Uri.parse(urlStr);
|
||||
|
||||
final resp = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
final resp = await _client
|
||||
.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncPullResponse.fromJson(data);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,21 +139,25 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/sync/push');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
final sTime = data['data']?['server_time'] as String?;
|
||||
return sTime ?? DateTime.now().toUtc().toIso8601String();
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user