BeeCount(蜜蜂記賬)是一款開源、簡(jiǎn)潔、無(wú)廣告的個(gè)人記賬應(yīng)用。所有財(cái)務(wù)數(shù)據(jù)完全由用戶掌控,支持本地存儲(chǔ)和可選的云端同步,確保數(shù)據(jù)絕對(duì)安全。
引言
在現(xiàn)代移動(dòng)應(yīng)用開發(fā)中,多設(shè)備數(shù)據(jù)同步已成為用戶的基本需求。用戶希望在手機(jī)、平板、不同設(shè)備間無(wú)縫切換,同時(shí)保持?jǐn)?shù)據(jù)的一致性和安全性。BeeCount選擇Supabase作為云端后臺(tái)服務(wù)https://www.naquan.com/,不僅因?yàn)槠溟_源特性和強(qiáng)大功能,更重要的是它提供了完整的數(shù)據(jù)安全保障。
Supabase架構(gòu)優(yōu)勢(shì)
開源與自主可控
開源透明:完全開源的后臺(tái)即服務(wù)(BaaS)解決方案
數(shù)據(jù)主權(quán):支持自建部署,數(shù)據(jù)完全可控
標(biāo)準(zhǔn)技術(shù):基于PostgreSQL,無(wú)廠商鎖定風(fēng)險(xiǎn)
功能完整性
實(shí)時(shí)數(shù)據(jù)庫(kù):基于PostgreSQL的實(shí)時(shí)數(shù)據(jù)同步
用戶認(rèn)證:完整的身份驗(yàn)證和授權(quán)系統(tǒng)
文件存儲(chǔ):對(duì)象存儲(chǔ)服務(wù),支持大文件上傳
邊緣函數(shù):服務(wù)端邏輯處理能力
同步架構(gòu)設(shè)計(jì)
整體架構(gòu)圖
┌─────────────────┐? ? ┌──────────────────┐? ? ┌─────────────────┐
│? Flutter App? │? ? │? Supabase? ? ? │? ? │? Other Device? │
│? (Local SQLite) │?──?│? (PostgreSQL)? ? │?──?│? (Local SQLite) │
│? ? ? ? ? ? ? ? │? ? │? (Auth + Storage) │? ? │? ? ? ? ? ? ? ? │
└─────────────────┘? ? └──────────────────┘? ? └─────────────────┘
? ? ? ? │? ? ? ? ? ? ? ? ? ? ? │? ? ? ? ? ? ? ? ? ? ? │
? ? ? ? └───── 加密備份文件 ─────┴───── 加密備份文件 ─────┘
核心設(shè)計(jì)原則
本地優(yōu)先:所有操作優(yōu)先在本地完成,確保響應(yīng)速度
增量同步:只同步變更數(shù)據(jù),降低網(wǎng)絡(luò)開銷
端到端加密:敏感數(shù)據(jù)在客戶端加密后上傳
沖突處理:合理的沖突解決策略
離線可用:網(wǎng)絡(luò)異常時(shí)應(yīng)用仍可正常使用
認(rèn)證系統(tǒng)集成
Supabase認(rèn)證配置
class SupabaseAuthService implements AuthService {
? final s.SupabaseClient client;
? SupabaseAuthService({required this.client});
? @override
? Future<AuthResult> signInWithEmail({
? ? required String email,
? ? required String password,
? }) async {
? ? try {
? ? ? final response = await client.auth.signInWithPassword(
? ? ? ? email: email,
? ? ? ? password: password,
? ? ? );
? ? ? if (response.user != null) {
? ? ? ? return AuthResult.success(user: AppUser.fromSupabase(response.user!));
? ? ? } else {
? ? ? ? return AuthResult.failure(error: 'Login failed');
? ? ? }
? ? } catch (e) {
? ? ? return AuthResult.failure(error: e.toString());
? ? }
? }
? @override
? Future<AuthResult> signUpWithEmail({
? ? required String email,
? ? required String password,
? }) async {
? ? try {
? ? ? final response = await client.auth.signUp(
? ? ? ? email: email,
? ? ? ? password: password,
? ? ? );
? ? ? return AuthResult.success(user: AppUser.fromSupabase(response.user!));
? ? } catch (e) {
? ? ? return AuthResult.failure(error: e.toString());
? ? }
? }
? @override
? Stream<AuthState> get authStateChanges {
? ? return client.auth.onAuthStateChange.map((data) {
? ? ? if (data.session?.user != null) {
? ? ? ? return AuthState.authenticated(
? ? ? ? ? user: AppUser.fromSupabase(data.session!.user)
? ? ? ? );
? ? ? }
? ? ? return AuthState.unauthenticated();
? ? });
? }
}
用戶模型設(shè)計(jì)
class AppUser {
? final String id;
? final String email;
? final DateTime? lastSignInAt;
? const AppUser({
? ? required this.id,
? ? required this.email,
? ? this.lastSignInAt,
? });
? factory AppUser.fromSupabase(s.User user) {
? ? return AppUser(
? ? ? id: user.id,
? ? ? email: user.email ?? '',
? ? ? lastSignInAt: user.lastSignInAt,
? ? );
? }
? bool get isAnonymous => email.isEmpty;
}
數(shù)據(jù)同步策略
備份文件格式
BeeCount采用加密備份文件的方式進(jìn)行數(shù)據(jù)同步:
class BackupData {
? final String version;
? final DateTime createdAt;
? final String deviceId;
? final Map<String, dynamic> ledgers;
? final Map<String, dynamic> accounts;
? final Map<String, dynamic> categories;
? final Map<String, dynamic> transactions;
? BackupData({
? ? required this.version,
? ? required this.createdAt,
? ? required this.deviceId,
? ? required this.ledgers,
? ? required this.accounts,
? ? required this.categories,
? ? required this.transactions,
? });
? Map<String, dynamic> toJson() => {
? ? 'version': version,
? ? 'createdAt': createdAt.toIso8601String(),
? ? 'deviceId': deviceId,
? ? 'ledgers': ledgers,
? ? 'accounts': accounts,
? ? 'categories': categories,
? ? 'transactions': transactions,
? };
? factory BackupData.fromJson(Map<String, dynamic> json) {
? ? return BackupData(
? ? ? version: json['version'],
? ? ? createdAt: DateTime.parse(json['createdAt']),
? ? ? deviceId: json['deviceId'],
? ? ? ledgers: json['ledgers'],
? ? ? accounts: json['accounts'],
? ? ? categories: json['categories'],
? ? ? transactions: json['transactions'],
? ? );
? }
}
同步服務(wù)實(shí)現(xiàn)
class SupabaseSyncService implements SyncService {
? final s.SupabaseClient client;
? final BeeDatabase db;
? final BeeRepository repo;
? final AuthService auth;
? final String bucket;
? // 狀態(tài)緩存和上傳窗口管理
? final Map<int, SyncStatus> _statusCache = {};
? final Map<int, _RecentUpload> _recentUpload = {};
? final Map<int, DateTime> _recentLocalChangeAt = {};
? SupabaseSyncService({
? ? required this.client,
? ? required this.db,
? ? required this.repo,
? ? required this.auth,
? ? this.bucket = 'beecount-backups',
? });
? @override
? Future<SyncStatus> getSyncStatus(int ledgerId) async {
? ? // 檢查緩存
? ? if (_statusCache.containsKey(ledgerId)) {
? ? ? final cached = _statusCache[ledgerId]!;
? ? ? if (DateTime.now().difference(cached.lastCheck).inMinutes < 5) {
? ? ? ? return cached;
? ? ? }
? ? }
? ? try {
? ? ? final user = await auth.getCurrentUser();
? ? ? if (user == null) {
? ? ? ? return SyncStatus.notLoggedIn();
? ? ? }
? ? ? // 獲取云端文件信息
? ? ? final fileName = 'ledger_${ledgerId}_backup.json';
? ? ? final cloudFile = await _getCloudFileInfo(fileName);
? ? ? // 計(jì)算本地?cái)?shù)據(jù)指紋
? ? ? final localFingerprint = await _calculateLocalFingerprint(ledgerId);
? ? ? if (cloudFile == null) {
? ? ? ? final status = SyncStatus.localOnly(
? ? ? ? ? localFingerprint: localFingerprint,
? ? ? ? ? hasLocalChanges: true,
? ? ? ? );
? ? ? ? _statusCache[ledgerId] = status;
? ? ? ? return status;
? ? ? }
? ? ? // 比較指紋判斷同步狀態(tài)
? ? ? final isUpToDate = localFingerprint == cloudFile.fingerprint;
? ? ? final status = SyncStatus.synced(
? ? ? ? localFingerprint: localFingerprint,
? ? ? ? cloudFingerprint: cloudFile.fingerprint,
? ? ? ? isUpToDate: isUpToDate,
? ? ? ? lastSyncAt: cloudFile.lastModified,
? ? ? );
? ? ? _statusCache[ledgerId] = status;
? ? ? return status;
? ? } catch (e) {
? ? ? logger.error('Failed to get sync status', e);
? ? ? return SyncStatus.error(error: e.toString());
? ? }
? }
? @override
? Future<SyncResult> uploadBackup(int ledgerId) async {
? ? try {
? ? ? final user = await auth.getCurrentUser();
? ? ? if (user == null) {
? ? ? ? return SyncResult.failure(error: 'Not logged in');
? ? ? }
? ? ? // 生成備份數(shù)據(jù)
? ? ? final backupData = await _generateBackup(ledgerId);
? ? ? final jsonString = json.encode(backupData.toJson());
? ? ? // 加密備份數(shù)據(jù)
? ? ? final encryptedData = await _encryptBackupData(jsonString);
? ? ? // 上傳到Supabase Storage
? ? ? final fileName = 'ledger_${ledgerId}_backup.json';
? ? ? final uploadResult = await client.storage
? ? ? ? ? .from(bucket)
? ? ? ? ? .uploadBinary(fileName, encryptedData);
? ? ? if (uploadResult.isNotEmpty) {
? ? ? ? // 記錄上傳成功
? ? ? ? final fingerprint = await _calculateLocalFingerprint(ledgerId);
? ? ? ? _recentUpload[ledgerId] = _RecentUpload(
? ? ? ? ? fingerprint: fingerprint,
? ? ? ? ? uploadedAt: DateTime.now(),
? ? ? ? );
? ? ? ? // 更新緩存
? ? ? ? _statusCache[ledgerId] = SyncStatus.synced(
? ? ? ? ? localFingerprint: fingerprint,
? ? ? ? ? cloudFingerprint: fingerprint,
? ? ? ? ? isUpToDate: true,
? ? ? ? ? lastSyncAt: DateTime.now(),
? ? ? ? );
? ? ? ? return SyncResult.success(
? ? ? ? ? syncedAt: DateTime.now(),
? ? ? ? ? message: 'Backup uploaded successfully',
? ? ? ? );
? ? ? }
? ? ? return SyncResult.failure(error: 'Upload failed');
? ? } catch (e) {
? ? ? logger.error('Failed to upload backup', e);
? ? ? return SyncResult.failure(error: e.toString());
? ? }
? }
? @override
? Future<SyncResult> downloadRestore(int ledgerId) async {
? ? try {
? ? ? final user = await auth.getCurrentUser();
? ? ? if (user == null) {
? ? ? ? return SyncResult.failure(error: 'Not logged in');
? ? ? }
? ? ? // 下載備份文件
? ? ? final fileName = 'ledger_${ledgerId}_backup.json';
? ? ? final downloadData = await client.storage
? ? ? ? ? .from(bucket)
? ? ? ? ? .download(fileName);
? ? ? if (downloadData.isEmpty) {
? ? ? ? return SyncResult.failure(error: 'No backup found');
? ? ? }
? ? ? // 解密備份數(shù)據(jù)
? ? ? final decryptedData = await _decryptBackupData(downloadData);
? ? ? final backupData = BackupData.fromJson(json.decode(decryptedData));
? ? ? // 執(zhí)行數(shù)據(jù)恢復(fù)
? ? ? await _restoreFromBackup(backupData, ledgerId);
? ? ? // 更新狀態(tài)
? ? ? final fingerprint = await _calculateLocalFingerprint(ledgerId);
? ? ? _statusCache[ledgerId] = SyncStatus.synced(
? ? ? ? localFingerprint: fingerprint,
? ? ? ? cloudFingerprint: fingerprint,
? ? ? ? isUpToDate: true,
? ? ? ? lastSyncAt: DateTime.now(),
? ? ? );
? ? ? return SyncResult.success(
? ? ? ? syncedAt: DateTime.now(),
? ? ? ? message: 'Data restored successfully',
? ? ? );
? ? } catch (e) {
? ? ? logger.error('Failed to download restore', e);
? ? ? return SyncResult.failure(error: e.toString());
? ? }
? }
? // 數(shù)據(jù)加密/解密
? Future<Uint8List> _encryptBackupData(String jsonData) async {
? ? final key = await _getDerivedKey();
? ? final cipher = AESCipher(key);
? ? return cipher.encrypt(utf8.encode(jsonData));
? }
? Future<String> _decryptBackupData(Uint8List encryptedData) async {
? ? final key = await _getDerivedKey();
? ? final cipher = AESCipher(key);
? ? final decrypted = cipher.decrypt(encryptedData);
? ? return utf8.decode(decrypted);
? }
}
數(shù)據(jù)安全保障
端到端加密
class AESCipher {
? final Uint8List key;
? AESCipher(this.key);
? Uint8List encrypt(List<int> plaintext) {
? ? final cipher = AESEngine()
? ? ? ..init(true, KeyParameter(key));
? ? // 生成隨機(jī)IV
? ? final iv = _generateRandomIV();
? ? final cbcCipher = CBCBlockCipher(cipher)
? ? ? ..init(true, ParametersWithIV(KeyParameter(key), iv));
? ? // PKCS7填充
? ? final paddedPlaintext = _padPKCS7(Uint8List.fromList(plaintext));
? ? final ciphertext = Uint8List(paddedPlaintext.length);
? ? for (int i = 0; i < paddedPlaintext.length; i += 16) {
? ? ? cbcCipher.processBlock(paddedPlaintext, i, ciphertext, i);
? ? }
? ? // IV + 密文
? ? return Uint8List.fromList([...iv, ...ciphertext]);
? }
? Uint8List decrypt(Uint8List encrypted) {
? ? // 分離IV和密文
? ? final iv = encrypted.sublist(0, 16);
? ? final ciphertext = encrypted.sublist(16);
? ? final cipher = AESEngine()
? ? ? ..init(false, KeyParameter(key));
? ? final cbcCipher = CBCBlockCipher(cipher)
? ? ? ..init(false, ParametersWithIV(KeyParameter(key), iv));
? ? final decrypted = Uint8List(ciphertext.length);
? ? for (int i = 0; i < ciphertext.length; i += 16) {
? ? ? cbcCipher.processBlock(ciphertext, i, decrypted, i);
? ? }
? ? // 移除PKCS7填充
? ? return _removePKCS7Padding(decrypted);
? }
? Uint8List _generateRandomIV() {
? ? final random = Random.secure();
? ? return Uint8List.fromList(
? ? ? List.generate(16, (_) => random.nextInt(256))
? ? );
? }
}
密鑰派生
Future<Uint8List> _getDerivedKey() async {
? final user = await auth.getCurrentUser();
? if (user == null) throw Exception('User not authenticated');
? // 使用用戶ID和設(shè)備特征生成密鑰
? final salt = utf8.encode('${user.id}_${await _getDeviceId()}');
? final password = utf8.encode(user.id);
? // PBKDF2密鑰派生
? final pbkdf2 = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64))
? ? ..init(Pbkdf2Parameters(salt, 10000, 32));
? return pbkdf2.process(password);
}
Future<String> _getDeviceId() async {
? final prefs = await SharedPreferences.getInstance();
? String? deviceId = prefs.getString('device_id');
? if (deviceId == null) {
? ? deviceId = const Uuid().v4();
? ? await prefs.setString('device_id', deviceId);
? }
? return deviceId;
}
沖突處理策略
沖突檢測(cè)
class ConflictDetector {
? static ConflictResolution detectConflict({
? ? required BackupData localData,
? ? required BackupData cloudData,
? ? required DateTime lastSyncAt,
? }) {
? ? final localChanges = <String, dynamic>{};
? ? final cloudChanges = <String, dynamic>{};
? ? // 檢測(cè)交易記錄沖突
? ? _detectTransactionConflicts(
? ? ? localData.transactions,
? ? ? cloudData.transactions,
? ? ? lastSyncAt,
? ? ? localChanges,
? ? ? cloudChanges,
? ? );
? ? if (localChanges.isEmpty && cloudChanges.isEmpty) {
? ? ? return ConflictResolution.noConflict();
? ? }
? ? if (localChanges.isNotEmpty && cloudChanges.isEmpty) {
? ? ? return ConflictResolution.localWins(changes: localChanges);
? ? }
? ? if (localChanges.isEmpty && cloudChanges.isNotEmpty) {
? ? ? return ConflictResolution.cloudWins(changes: cloudChanges);
? ? }
? ? // 存在雙向沖突,需要用戶選擇
? ? return ConflictResolution.needsResolution(
? ? ? localChanges: localChanges,
? ? ? cloudChanges: cloudChanges,
? ? );
? }
? static void _detectTransactionConflicts(
? ? Map<String, dynamic> localTxs,
? ? Map<String, dynamic> cloudTxs,
? ? DateTime lastSyncAt,
? ? Map<String, dynamic> localChanges,
? ? Map<String, dynamic> cloudChanges,
? ) {
? ? // 檢測(cè)本地新增/修改的交易
? ? localTxs.forEach((id, localTx) {
? ? ? final txUpdatedAt = DateTime.parse(localTx['updatedAt'] ?? localTx['createdAt']);
? ? ? if (txUpdatedAt.isAfter(lastSyncAt)) {
? ? ? ? localChanges[id] = localTx;
? ? ? }
? ? });
? ? // 檢測(cè)云端新增/修改的交易
? ? cloudTxs.forEach((id, cloudTx) {
? ? ? final txUpdatedAt = DateTime.parse(cloudTx['updatedAt'] ?? cloudTx['createdAt']);
? ? ? if (txUpdatedAt.isAfter(lastSyncAt)) {
? ? ? ? cloudChanges[id] = cloudTx;
? ? ? }
? ? });
? }
}
沖突解決
class ConflictResolver {
? static Future<BackupData> resolveConflict({
? ? required BackupData localData,
? ? required BackupData cloudData,
? ? required ConflictResolution resolution,
? }) async {
? ? switch (resolution.type) {
? ? ? case ConflictType.noConflict:
? ? ? ? return localData;
? ? ? case ConflictType.localWins:
? ? ? ? return localData;
? ? ? case ConflictType.cloudWins:
? ? ? ? return cloudData;
? ? ? case ConflictType.needsResolution:
? ? ? ? return await _mergeData(localData, cloudData, resolution);
? ? }
? }
? static Future<BackupData> _mergeData(
? ? BackupData localData,
? ? BackupData cloudData,
? ? ConflictResolution resolution,
? ) async {
? ? // 實(shí)現(xiàn)智能合并策略
? ? final mergedTransactions = <String, dynamic>{};
? ? // 優(yōu)先保留較新的數(shù)據(jù)
? ? mergedTransactions.addAll(cloudData.transactions);
? ? resolution.localChanges.forEach((id, localTx) {
? ? ? final localUpdatedAt = DateTime.parse(localTx['updatedAt'] ?? localTx['createdAt']);
? ? ? if (resolution.cloudChanges.containsKey(id)) {
? ? ? ? final cloudTx = resolution.cloudChanges[id];
? ? ? ? final cloudUpdatedAt = DateTime.parse(cloudTx['updatedAt'] ?? cloudTx['createdAt']);
? ? ? ? // 保留時(shí)間戳較新的版本
? ? ? ? if (localUpdatedAt.isAfter(cloudUpdatedAt)) {
? ? ? ? ? mergedTransactions[id] = localTx;
? ? ? ? } else {
? ? ? ? ? mergedTransactions[id] = cloudTx;
? ? ? ? }
? ? ? } else {
? ? ? ? mergedTransactions[id] = localTx;
? ? ? }
? ? });
? ? return BackupData(
? ? ? version: localData.version,
? ? ? createdAt: DateTime.now(),
? ? ? deviceId: localData.deviceId,
? ? ? ledgers: localData.ledgers,
? ? ? accounts: localData.accounts,
? ? ? categories: localData.categories,
? ? ? transactions: mergedTransactions,
? ? );
? }
}
性能優(yōu)化
增量同步優(yōu)化
class IncrementalSync {
? static Future<BackupData> generateIncrementalBackup({
? ? required int ledgerId,
? ? required DateTime lastSyncAt,
? ? required BeeRepository repo,
? }) async {
? ? // 只獲取自上次同步后的變更數(shù)據(jù)
? ? final changedTransactions = await repo.getTransactionsSince(
? ? ? ledgerId: ledgerId,
? ? ? since: lastSyncAt,
? ? );
? ? final changedAccounts = await repo.getAccountsSince(
? ? ? ledgerId: ledgerId,
? ? ? since: lastSyncAt,
? ? );
? ? // 構(gòu)建增量備份數(shù)據(jù)
? ? return BackupData(
? ? ? version: '1.0',
? ? ? createdAt: DateTime.now(),
? ? ? deviceId: await _getDeviceId(),
? ? ? ledgers: {}, // 賬本信息變化較少,可按需包含
? ? ? accounts: _mapAccountsToJson(changedAccounts),
? ? ? categories: {}, // 分類變化較少,可按需包含
? ? ? transactions: _mapTransactionsToJson(changedTransactions),
? ? );
? }
? static Map<String, dynamic> _mapTransactionsToJson(List<Transaction> transactions) {
? ? return Map.fromEntries(
? ? ? transactions.map((tx) => MapEntry(
? ? ? ? tx.id.toString(),
? ? ? ? {
? ? ? ? ? 'id': tx.id,
? ? ? ? ? 'ledgerId': tx.ledgerId,
? ? ? ? ? 'type': tx.type,
? ? ? ? ? 'amount': tx.amount,
? ? ? ? ? 'categoryId': tx.categoryId,
? ? ? ? ? 'accountId': tx.accountId,
? ? ? ? ? 'toAccountId': tx.toAccountId,
? ? ? ? ? 'happenedAt': tx.happenedAt.toIso8601String(),
? ? ? ? ? 'note': tx.note,
? ? ? ? ? 'updatedAt': DateTime.now().toIso8601String(),
? ? ? ? }
? ? ? ))
? ? );
? }
}
網(wǎng)絡(luò)優(yōu)化
class NetworkOptimizer {
? static const int maxRetries = 3;
? static const Duration retryDelay = Duration(seconds: 2);
? static Future<T> withRetry<T>(Future<T> Function() operation) async {
? ? int attempts = 0;
? ? while (attempts < maxRetries) {
? ? ? try {
? ? ? ? return await operation();
? ? ? } catch (e) {
? ? ? ? attempts++;
? ? ? ? if (attempts >= maxRetries) {
? ? ? ? ? rethrow;
? ? ? ? }
? ? ? ? // 指數(shù)退避
? ? ? ? await Future.delayed(retryDelay * (1 << attempts));
? ? ? }
? ? }
? ? throw Exception('Max retries exceeded');
? }
? static Future<bool> isNetworkAvailable() async {
? ? try {
? ? ? final result = await InternetAddress.lookup('supabase.co');
? ? ? return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
? ? } catch (_) {
? ? ? return false;
? ? }
? }
}
用戶體驗(yàn)設(shè)計(jì)
同步狀態(tài)展示
class SyncStatusWidget extends ConsumerWidget {
? final int ledgerId;
? const SyncStatusWidget({Key? key, required this.ledgerId}) : super(key: key);
? @override
? Widget build(BuildContext context, WidgetRef ref) {
? ? final syncStatus = ref.watch(syncStatusProvider(ledgerId));
? ? return syncStatus.when(
? ? ? data: (status) => _buildStatusIndicator(status),
? ? ? loading: () => const SyncLoadingIndicator(),
? ? ? error: (error, _) => SyncErrorIndicator(error: error.toString()),
? ? );
? }
? Widget _buildStatusIndicator(SyncStatus status) {
? ? switch (status.type) {
? ? ? case SyncStatusType.synced:
? ? ? ? return Row(
? ? ? ? ? mainAxisSize: MainAxisSize.min,
? ? ? ? ? children: [
? ? ? ? ? ? Icon(Icons.cloud_done, color: Colors.green, size: 16),
? ? ? ? ? ? SizedBox(width: 4),
? ? ? ? ? ? Text('已同步', style: TextStyle(fontSize: 12, color: Colors.green)),
? ? ? ? ? ],
? ? ? ? );
? ? ? case SyncStatusType.localOnly:
? ? ? ? return Row(
? ? ? ? ? mainAxisSize: MainAxisSize.min,
? ? ? ? ? children: [
? ? ? ? ? ? Icon(Icons.cloud_off, color: Colors.orange, size: 16),
? ? ? ? ? ? SizedBox(width: 4),
? ? ? ? ? ? Text('僅本地', style: TextStyle(fontSize: 12, color: Colors.orange)),
? ? ? ? ? ],
? ? ? ? );
? ? ? case SyncStatusType.needsUpload:
? ? ? ? return Row(
? ? ? ? ? mainAxisSize: MainAxisSize.min,
? ? ? ? ? children: [
? ? ? ? ? ? Icon(Icons.cloud_upload, color: Colors.blue, size: 16),
? ? ? ? ? ? SizedBox(width: 4),
? ? ? ? ? ? Text('待上傳', style: TextStyle(fontSize: 12, color: Colors.blue)),
? ? ? ? ? ],
? ? ? ? );
? ? ? case SyncStatusType.needsDownload:
? ? ? ? return Row(
? ? ? ? ? mainAxisSize: MainAxisSize.min,
? ? ? ? ? children: [
? ? ? ? ? ? Icon(Icons.cloud_download, color: Colors.purple, size: 16),
? ? ? ? ? ? SizedBox(width: 4),
? ? ? ? ? ? Text('有更新', style: TextStyle(fontSize: 12, color: Colors.purple)),
? ? ? ? ? ],
? ? ? ? );
? ? ? default:
? ? ? ? return SizedBox.shrink();
? ? }
? }
}
同步操作界面
class SyncActionsSheet extends ConsumerWidget {
? final int ledgerId;
? const SyncActionsSheet({Key? key, required this.ledgerId}) : super(key: key);
? @override
? Widget build(BuildContext context, WidgetRef ref) {
? ? final syncStatus = ref.watch(syncStatusProvider(ledgerId));
? ? return DraggableScrollableSheet(
? ? ? initialChildSize: 0.4,
? ? ? minChildSize: 0.2,
? ? ? maxChildSize: 0.8,
? ? ? builder: (context, scrollController) {
? ? ? ? return Container(
? ? ? ? ? decoration: BoxDecoration(
? ? ? ? ? ? color: Theme.of(context).scaffoldBackgroundColor,
? ? ? ? ? ? borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
? ? ? ? ? ),
? ? ? ? ? child: Column(
? ? ? ? ? ? children: [
? ? ? ? ? ? ? // 拖拽指示器
? ? ? ? ? ? ? Container(
? ? ? ? ? ? ? ? width: 40,
? ? ? ? ? ? ? ? height: 4,
? ? ? ? ? ? ? ? margin: EdgeInsets.symmetric(vertical: 12),
? ? ? ? ? ? ? ? decoration: BoxDecoration(
? ? ? ? ? ? ? ? ? color: Colors.grey[300],
? ? ? ? ? ? ? ? ? borderRadius: BorderRadius.circular(2),
? ? ? ? ? ? ? ? ),
? ? ? ? ? ? ? ),
? ? ? ? ? ? ? // 標(biāo)題
? ? ? ? ? ? ? Text(
? ? ? ? ? ? ? ? '云端同步',
? ? ? ? ? ? ? ? style: Theme.of(context).textTheme.headlineSmall,
? ? ? ? ? ? ? ),
? ? ? ? ? ? ? Expanded(
? ? ? ? ? ? ? ? child: ListView(
? ? ? ? ? ? ? ? ? controller: scrollController,
? ? ? ? ? ? ? ? ? padding: EdgeInsets.all(16),
? ? ? ? ? ? ? ? ? children: [
? ? ? ? ? ? ? ? ? ? _buildSyncActions(context, ref, syncStatus),
? ? ? ? ? ? ? ? ? ],
? ? ? ? ? ? ? ? ),
? ? ? ? ? ? ? ),
? ? ? ? ? ? ],
? ? ? ? ? ),
? ? ? ? );
? ? ? },
? ? );
? }
? Widget _buildSyncActions(BuildContext context, WidgetRef ref, AsyncValue<SyncStatus> syncStatus) {
? ? return syncStatus.when(
? ? ? data: (status) {
? ? ? ? switch (status.type) {
? ? ? ? ? case SyncStatusType.localOnly:
? ? ? ? ? ? return Column(
? ? ? ? ? ? ? children: [
? ? ? ? ? ? ? ? _buildActionCard(
? ? ? ? ? ? ? ? ? title: '上傳備份',
? ? ? ? ? ? ? ? ? subtitle: '將本地?cái)?shù)據(jù)上傳到云端',
? ? ? ? ? ? ? ? ? icon: Icons.cloud_upload,
? ? ? ? ? ? ? ? ? color: Colors.blue,
? ? ? ? ? ? ? ? ? onTap: () => _uploadBackup(ref),
? ? ? ? ? ? ? ? ),
? ? ? ? ? ? ? ],
? ? ? ? ? ? );
? ? ? ? ? case SyncStatusType.needsDownload:
? ? ? ? ? ? return Column(
? ? ? ? ? ? ? children: [
? ? ? ? ? ? ? ? _buildActionCard(
? ? ? ? ? ? ? ? ? title: '下載恢復(fù)',
? ? ? ? ? ? ? ? ? subtitle: '從云端恢復(fù)數(shù)據(jù)(會(huì)覆蓋本地?cái)?shù)據(jù))',
? ? ? ? ? ? ? ? ? icon: Icons.cloud_download,
? ? ? ? ? ? ? ? ? color: Colors.purple,
? ? ? ? ? ? ? ? ? onTap: () => _downloadRestore(context, ref),
? ? ? ? ? ? ? ? ),
? ? ? ? ? ? ? ? SizedBox(height: 16),
? ? ? ? ? ? ? ? _buildActionCard(
? ? ? ? ? ? ? ? ? title: '強(qiáng)制上傳',
? ? ? ? ? ? ? ? ? subtitle: '用本地?cái)?shù)據(jù)覆蓋云端備份',
? ? ? ? ? ? ? ? ? icon: Icons.upload,
? ? ? ? ? ? ? ? ? color: Colors.orange,
? ? ? ? ? ? ? ? ? onTap: () => _forceUpload(context, ref),
? ? ? ? ? ? ? ? ),
? ? ? ? ? ? ? ],
? ? ? ? ? ? );
? ? ? ? ? default:
? ? ? ? ? ? return _buildSyncInfo(status);
? ? ? ? }
? ? ? },
? ? ? loading: () => Center(child: CircularProgressIndicator()),
? ? ? error: (error, _) => Text('錯(cuò)誤: $error'),
? ? );
? }
? Widget _buildActionCard({
? ? required String title,
? ? required String subtitle,
? ? required IconData icon,
? ? required Color color,
? ? required VoidCallback onTap,
? }) {
? ? return Card(
? ? ? child: ListTile(
? ? ? ? leading: CircleAvatar(
? ? ? ? ? backgroundColor: color.withOpacity(0.1),
? ? ? ? ? child: Icon(icon, color: color),
? ? ? ? ),
? ? ? ? title: Text(title),
? ? ? ? subtitle: Text(subtitle),
? ? ? ? trailing: Icon(Icons.arrow_forward_ios, size: 16),
? ? ? ? onTap: onTap,
? ? ? ),
? ? );
? }
}
最佳實(shí)踐總結(jié)
1. 架構(gòu)設(shè)計(jì)原則
本地優(yōu)先:確保應(yīng)用離線可用
漸進(jìn)式同步:支持部分同步,不影響核心功能
狀態(tài)透明:讓用戶清楚了解同步狀態(tài)
2. 安全性考慮
端到端加密:敏感數(shù)據(jù)客戶端加密
密鑰管理:使用安全的密鑰派生和存儲(chǔ)
權(quán)限控制:確保用戶只能訪問自己的數(shù)據(jù)
3. 性能優(yōu)化
增量同步:只傳輸變更數(shù)據(jù)
壓縮上傳:減少網(wǎng)絡(luò)傳輸量
后臺(tái)同步:不影響用戶操作
4. 用戶體驗(yàn)
狀態(tài)可見:清晰的同步狀態(tài)指示
操作簡(jiǎn)單:一鍵同步,自動(dòng)處理
錯(cuò)誤友好:明確的錯(cuò)誤提示和恢復(fù)建議
實(shí)際應(yīng)用效果
在BeeCount項(xiàng)目中,Supabase云同步系統(tǒng)帶來了顯著的價(jià)值:
用戶滿意度提升:多設(shè)備無(wú)縫切換,用戶數(shù)據(jù)永不丟失
技術(shù)債務(wù)減少:基于成熟的BaaS服務(wù),減少自建后臺(tái)成本
安全性保障:端到端加密確保財(cái)務(wù)數(shù)據(jù)安全
開發(fā)效率:快速集成,專注業(yè)務(wù)邏輯開發(fā)
結(jié)語(yǔ)
Supabase作為開源的BaaS解決方案,為Flutter應(yīng)用提供了完整的后端服務(wù)能力。通過合理的架構(gòu)設(shè)計(jì)、安全的加密策略和良好的用戶體驗(yàn)設(shè)計(jì),我們可以構(gòu)建出既安全又好用的云同步功能。
BeeCount的實(shí)踐證明,選擇合適的技術(shù)棧和設(shè)計(jì)模式,能夠在保證數(shù)據(jù)安全的前提下,為用戶提供便捷的多設(shè)備同步體驗(yàn)。這對(duì)于任何需要數(shù)據(jù)同步的應(yīng)用都具有重要的參考價(jià)值。