feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'sync_merger.dart';
|
||||
import 'sync_models.dart';
|
||||
import 'sync_service.dart';
|
||||
|
||||
/// 跨端学习进度同步协调器
|
||||
class SyncCoordinator extends ChangeNotifier {
|
||||
static const _prefKey = 'sync_config_v1';
|
||||
|
||||
static final SyncCoordinator instance = SyncCoordinator._();
|
||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||
|
||||
@visibleForTesting
|
||||
factory SyncCoordinator.createForTesting({SyncService? service}) {
|
||||
return SyncCoordinator._(service: service);
|
||||
}
|
||||
|
||||
final SyncService _service;
|
||||
|
||||
SyncConfig _config = const SyncConfig();
|
||||
SyncState _state = SyncState.idle;
|
||||
String? _errorMessage;
|
||||
bool _isInitialized = false;
|
||||
DateTime? _lastSyncAttempt;
|
||||
|
||||
SyncConfig get config => _config;
|
||||
SyncState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isLoggedIn => _config.isLoggedIn;
|
||||
String? get username => _config.username;
|
||||
String get serverUrl => _config.serverUrl;
|
||||
DateTime? get lastSyncTime => _config.lastSyncTime;
|
||||
bool get autoSyncEnabled => _config.autoSyncEnabled;
|
||||
|
||||
/// 初始化并从本地存储加载配置
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefKey);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_config = SyncConfig.fromJson(map);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] init error: $e');
|
||||
} finally {
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefKey, jsonEncode(_config.toJson()));
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] save config error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新服务器地址
|
||||
Future<void> updateServerUrl(String newUrl) async {
|
||||
_config = _config.copyWith(serverUrl: newUrl.trim());
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 切换自动同步开关
|
||||
Future<void> setAutoSyncEnabled(bool enabled) async {
|
||||
_config = _config.copyWith(autoSyncEnabled: enabled);
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 注册新用户并自动保存登录凭证
|
||||
Future<bool> register({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final auth = await _service.register(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
userId: auth.userId,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录已有账号
|
||||
Future<bool> login({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final auth = await _service.login(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
userId: auth.userId,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出并清除本地登录凭证
|
||||
Future<void> logout() async {
|
||||
_config = SyncConfig(
|
||||
serverUrl: _config.serverUrl,
|
||||
autoSyncEnabled: _config.autoSyncEnabled,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 测试与服务器的连接
|
||||
Future<bool> testConnection([String? customUrl]) async {
|
||||
return _service.testConnection(customUrl ?? _config.serverUrl);
|
||||
}
|
||||
|
||||
/// 立即触发一次全量/增量双向同步 (Pull -> Merge -> Push)
|
||||
Future<bool> syncNow(AppState appState) async {
|
||||
if (!isLoggedIn) {
|
||||
_errorMessage = '未登录同步账号';
|
||||
_state = SyncState.error;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final sUrl = _config.serverUrl;
|
||||
final token = _config.token!;
|
||||
|
||||
// 1. 增量拉取云端数据
|
||||
final pullResp = await _service.pull(
|
||||
serverUrl: sUrl,
|
||||
token: token,
|
||||
since: _config.lastSyncTime,
|
||||
);
|
||||
|
||||
// 2. 本地智能合并 (CRDT/LWW)
|
||||
final changed = SyncMerger.applyPullResponse(appState, pullResp);
|
||||
if (changed) {
|
||||
appState.notifyListeners();
|
||||
}
|
||||
|
||||
// 3. 构建本地增量数据并推送至云端
|
||||
final pushReq = SyncMerger.buildPushRequest(appState);
|
||||
final sTime = await _service.push(
|
||||
serverUrl: sUrl,
|
||||
token: token,
|
||||
request: pushReq,
|
||||
);
|
||||
|
||||
// 4. 更新同步时间戳并保存
|
||||
final syncSuccessTime = DateTime.tryParse(sTime) ?? DateTime.now();
|
||||
_config = _config.copyWith(lastSyncTime: syncSuccessTime);
|
||||
_state = SyncState.success;
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 满足条件时在后台静默触发同步
|
||||
void triggerBackgroundSync(AppState appState) {
|
||||
if (!autoSyncEnabled || !isLoggedIn || _state == SyncState.syncing) {
|
||||
return;
|
||||
}
|
||||
// 简单防抖:距离上次同步尝试不足 3 秒则跳过
|
||||
final now = DateTime.now();
|
||||
if (_lastSyncAttempt != null &&
|
||||
now.difference(_lastSyncAttempt!).inSeconds < 3) {
|
||||
return;
|
||||
}
|
||||
_lastSyncAttempt = now;
|
||||
|
||||
// 异步执行,不阻塞主流程
|
||||
unawaited(syncNow(appState));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import '../models.dart';
|
||||
import '../app_state.dart';
|
||||
import 'sync_models.dart';
|
||||
|
||||
/// 负责本地 AppState 与云端 DTO 之间的序列化与智能合并
|
||||
class SyncMerger {
|
||||
/// 将本地 AppState 打包为增量推送请求
|
||||
static SyncPushRequest buildPushRequest(AppState state, {String? deviceName}) {
|
||||
final nowIso = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
final progressPayload = SyncProgressPayload(
|
||||
activeLessonId: state.activeLessonId,
|
||||
completedLessonIds: state.completedLessonIds.toList(),
|
||||
completedSegmentIds: state.completedSegmentIds.toList(),
|
||||
activeStep: state.lessonStep.name,
|
||||
streakDays: state.completedLessons > 0 ? 1 : 0,
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
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 attempts = review?.attempts ?? 0;
|
||||
final successfulReviews = review?.successfulReviews ?? 0;
|
||||
|
||||
return SyncMasteryItemPayload(
|
||||
itemId: m.id,
|
||||
checkpoint: m.checkpoint,
|
||||
status: m.status.name,
|
||||
dueAt: dueAt,
|
||||
successfulReviews: successfulReviews,
|
||||
attempts: attempts,
|
||||
payload: {
|
||||
'label': m.label,
|
||||
'evidence': m.evidence.map((e) => e.name).toList(),
|
||||
'needsReview': m.needsReview,
|
||||
if (m.firstTaughtAt != null)
|
||||
'firstTaughtAt': m.firstTaughtAt!.toUtc().toIso8601String(),
|
||||
},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final profilePayload = SyncProfilePayload(
|
||||
onboardingComplete: state.onboardingComplete,
|
||||
goal: state.goal.name,
|
||||
placement: state.placement.name,
|
||||
dailyMinutes: state.dailyMinutes,
|
||||
showChineseHints: state.showChineseHints,
|
||||
aiEndpoint: state.aiEndpoint,
|
||||
aiModel: state.aiModel,
|
||||
aiProvider: state.aiProvider.name,
|
||||
settingsPayload: {
|
||||
'keepRecordings': state.keepRecordings,
|
||||
},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
return SyncPushRequest(
|
||||
clientTime: nowIso,
|
||||
deviceName: deviceName,
|
||||
progress: progressPayload,
|
||||
masteryUpdates: masteryUpdates,
|
||||
profile: profilePayload,
|
||||
);
|
||||
}
|
||||
|
||||
/// 将云端拉取的进度合并到本地 AppState
|
||||
static bool applyPullResponse(AppState state, SyncPullResponse response) {
|
||||
var changed = false;
|
||||
|
||||
// 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;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 合并复习掌握项 (Max Checkpoint)
|
||||
for (final m in response.masteryUpdates) {
|
||||
final local = state.mastery[m.itemId];
|
||||
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,
|
||||
checkpoint: m.checkpoint,
|
||||
firstTaughtAt: m.payload['firstTaughtAt'] != null
|
||||
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
||||
: null,
|
||||
);
|
||||
changed = true;
|
||||
} else if (m.checkpoint > local.checkpoint) {
|
||||
// 云端 Checkpoint 更高,升级本地状态
|
||||
final status = _parseMasteryStatus(m.status);
|
||||
state.mastery[m.itemId] = local.copyWith(
|
||||
checkpoint: m.checkpoint,
|
||||
status: status,
|
||||
needsReview: m.payload['needsReview'] as bool? ?? local.needsReview,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 合并用户偏好设置 (按需合并)
|
||||
if (response.profile != null) {
|
||||
final prof = response.profile!;
|
||||
if (!state.onboardingComplete && prof.onboardingComplete) {
|
||||
state.onboardingComplete = true;
|
||||
changed = true;
|
||||
}
|
||||
if (prof.goal.isNotEmpty) {
|
||||
final g = _parseGoal(prof.goal);
|
||||
if (g != state.goal) {
|
||||
state.goal = g;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
static MasteryStatus _parseMasteryStatus(String str) {
|
||||
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
|
||||
MasteryStatus.newItem;
|
||||
}
|
||||
|
||||
static EvidenceKind _parseEvidenceKind(String str) {
|
||||
return EvidenceKind.values.where((e) => e.name == str).firstOrNull ??
|
||||
EvidenceKind.pending;
|
||||
}
|
||||
|
||||
static LearningGoal _parseGoal(String str) {
|
||||
return LearningGoal.values.where((e) => e.name == str).firstOrNull ??
|
||||
LearningGoal.dailyLife;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
/// 同步状态枚举
|
||||
enum SyncState {
|
||||
idle,
|
||||
syncing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 客户端同步配置与认证状态
|
||||
class SyncConfig {
|
||||
final String serverUrl;
|
||||
final String? token;
|
||||
final String? username;
|
||||
final String? userId;
|
||||
final DateTime? lastSyncTime;
|
||||
final bool autoSyncEnabled;
|
||||
|
||||
const SyncConfig({
|
||||
this.serverUrl = 'https://syncenglish.slcydia.fun',
|
||||
this.token,
|
||||
this.username,
|
||||
this.userId,
|
||||
this.lastSyncTime,
|
||||
this.autoSyncEnabled = true,
|
||||
});
|
||||
|
||||
bool get isLoggedIn => token != null && token!.isNotEmpty;
|
||||
|
||||
SyncConfig copyWith({
|
||||
String? serverUrl,
|
||||
String? token,
|
||||
String? username,
|
||||
String? userId,
|
||||
DateTime? lastSyncTime,
|
||||
bool? autoSyncEnabled,
|
||||
}) => SyncConfig(
|
||||
serverUrl: serverUrl ?? this.serverUrl,
|
||||
token: token ?? this.token,
|
||||
username: username ?? this.username,
|
||||
userId: userId ?? this.userId,
|
||||
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
|
||||
autoSyncEnabled: autoSyncEnabled ?? this.autoSyncEnabled,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'serverUrl': serverUrl,
|
||||
'token': token,
|
||||
'username': username,
|
||||
'userId': userId,
|
||||
'lastSyncTime': lastSyncTime?.toUtc().toIso8601String(),
|
||||
'autoSyncEnabled': autoSyncEnabled,
|
||||
};
|
||||
|
||||
factory SyncConfig.fromJson(Map<String, dynamic> json) => SyncConfig(
|
||||
serverUrl: json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
token: json['token'] as String?,
|
||||
username: json['username'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
lastSyncTime: json['lastSyncTime'] != null
|
||||
? DateTime.tryParse(json['lastSyncTime'] as String)
|
||||
: null,
|
||||
autoSyncEnabled: json['autoSyncEnabled'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 认证响应
|
||||
class SyncAuthResponse {
|
||||
final String userId;
|
||||
final String username;
|
||||
final String token;
|
||||
final int expiresIn;
|
||||
|
||||
const SyncAuthResponse({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.token,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 课程关卡进度 DTO
|
||||
class SyncProgressPayload {
|
||||
final String activeLessonId;
|
||||
final List<String> completedLessonIds;
|
||||
final List<String> completedSegmentIds;
|
||||
final String activeStep;
|
||||
final int streakDays;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncProgressPayload({
|
||||
required this.activeLessonId,
|
||||
required this.completedLessonIds,
|
||||
required this.completedSegmentIds,
|
||||
this.activeStep = 'preview',
|
||||
this.streakDays = 0,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'active_lesson_id': activeLessonId,
|
||||
'completed_lesson_ids': completedLessonIds,
|
||||
'completed_segment_ids': completedSegmentIds,
|
||||
'active_step': activeStep,
|
||||
'streak_days': streakDays,
|
||||
'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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 艾宾浩斯与掌握度 DTO
|
||||
class SyncMasteryItemPayload {
|
||||
final String itemId;
|
||||
final int checkpoint;
|
||||
final String status;
|
||||
final String dueAt;
|
||||
final int successfulReviews;
|
||||
final int attempts;
|
||||
final Map<String, dynamic> payload;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncMasteryItemPayload({
|
||||
required this.itemId,
|
||||
required this.checkpoint,
|
||||
required this.status,
|
||||
required this.dueAt,
|
||||
this.successfulReviews = 0,
|
||||
this.attempts = 0,
|
||||
this.payload = const {},
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'item_id': itemId,
|
||||
'checkpoint': checkpoint,
|
||||
'status': status,
|
||||
'due_at': dueAt,
|
||||
'successful_reviews': successfulReviews,
|
||||
'attempts': attempts,
|
||||
'payload': payload,
|
||||
'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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户画像与偏好 DTO
|
||||
class SyncProfilePayload {
|
||||
final bool onboardingComplete;
|
||||
final String goal;
|
||||
final String placement;
|
||||
final int dailyMinutes;
|
||||
final bool showChineseHints;
|
||||
final String aiEndpoint;
|
||||
final String aiModel;
|
||||
final String aiProvider;
|
||||
final Map<String, dynamic> settingsPayload;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncProfilePayload({
|
||||
this.onboardingComplete = true,
|
||||
this.goal = 'travel',
|
||||
this.placement = 'A0',
|
||||
this.dailyMinutes = 20,
|
||||
this.showChineseHints = true,
|
||||
this.aiEndpoint = '',
|
||||
this.aiModel = '',
|
||||
this.aiProvider = '',
|
||||
this.settingsPayload = const {},
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'onboarding_complete': onboardingComplete,
|
||||
'goal': goal,
|
||||
'placement': placement,
|
||||
'daily_minutes': dailyMinutes,
|
||||
'show_chinese_hints': showChineseHints,
|
||||
'ai_endpoint': aiEndpoint,
|
||||
'ai_model': aiModel,
|
||||
'ai_provider': aiProvider,
|
||||
'settings_payload': settingsPayload,
|
||||
'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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 增量推送请求
|
||||
class SyncPushRequest {
|
||||
final String clientTime;
|
||||
final String? deviceName;
|
||||
final SyncProgressPayload? progress;
|
||||
final List<SyncMasteryItemPayload> masteryUpdates;
|
||||
final SyncProfilePayload? profile;
|
||||
|
||||
const SyncPushRequest({
|
||||
required this.clientTime,
|
||||
this.deviceName,
|
||||
this.progress,
|
||||
this.masteryUpdates = const [],
|
||||
this.profile,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'client_time': clientTime,
|
||||
if (deviceName != null) 'device_name': deviceName,
|
||||
if (progress != null) 'progress': progress!.toJson(),
|
||||
'mastery_updates': masteryUpdates.map((m) => m.toJson()).toList(),
|
||||
if (profile != null) 'profile': profile!.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 增量拉取响应
|
||||
class SyncPullResponse {
|
||||
final String serverTime;
|
||||
final SyncProgressPayload? progress;
|
||||
final List<SyncMasteryItemPayload> masteryUpdates;
|
||||
final SyncProfilePayload? profile;
|
||||
|
||||
const SyncPullResponse({
|
||||
required this.serverTime,
|
||||
this.progress,
|
||||
this.masteryUpdates = const [],
|
||||
this.profile,
|
||||
});
|
||||
|
||||
factory SyncPullResponse.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] is Map<String, dynamic>
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return SyncPullResponse(
|
||||
serverTime: data['server_time'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
progress: data['progress'] != null
|
||||
? SyncProgressPayload.fromJson(data['progress'] as Map<String, dynamic>)
|
||||
: null,
|
||||
masteryUpdates: (data['mastery_updates'] as List? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => SyncMasteryItemPayload.fromJson(e))
|
||||
.toList(),
|
||||
profile: data['profile'] != null
|
||||
? SyncProfilePayload.fromJson(data['profile'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'sync_models.dart';
|
||||
|
||||
/// 负责与自建同步服务端通信的 HTTP 服务
|
||||
class SyncService {
|
||||
final http.Client _client;
|
||||
|
||||
SyncService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
String _cleanUrl(String url) {
|
||||
var u = url.trim();
|
||||
if (u.endsWith('/')) {
|
||||
u = u.substring(0, u.length - 1);
|
||||
}
|
||||
if (!u.startsWith('http://') && !u.startsWith('https://')) {
|
||||
u = 'http://$u';
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/// 测试与服务器的连通性
|
||||
Future<bool> testConnection(String serverUrl) async {
|
||||
try {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/health');
|
||||
final resp = await _client.get(uri).timeout(const Duration(seconds: 5));
|
||||
return resp.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册新用户
|
||||
Future<SyncAuthResponse> register({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? deviceName,
|
||||
}) 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 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}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录已有用户
|
||||
Future<SyncAuthResponse> login({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? deviceName,
|
||||
}) 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 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}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 增量拉取云端学习进度
|
||||
Future<SyncPullResponse> pull({
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
var urlStr = '$base/api/v1/sync/pull';
|
||||
if (since != null) {
|
||||
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 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}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送本地增量进度到云端
|
||||
Future<String> push({
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required SyncPushRequest request,
|
||||
}) async {
|
||||
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 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}');
|
||||
}
|
||||
}
|
||||
|
||||
String _getPlatformDeviceName() {
|
||||
if (Platform.isAndroid) return 'Android 客户端';
|
||||
if (Platform.isIOS) return 'iPhone 客户端';
|
||||
if (Platform.isMacOS) return 'macOS 桌面端';
|
||||
if (Platform.isWindows) return 'Windows 桌面端';
|
||||
if (Platform.isLinux) return 'Linux 客户端';
|
||||
return 'SpeakSprout Client';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user