feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署

This commit is contained in:
shen
2026-09-16 09:15:53 +08:00
parent 293b838341
commit eda5dfce62
66 changed files with 3564 additions and 80 deletions
@@ -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';
}
}