整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -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}) {
+13 -11
View File
@@ -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(),
+65 -54
View File
@@ -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>>()
+57 -40
View File
@@ -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}',
);
}
}