Files
fitness/mobile/lib/services/sync_service.dart
T

189 lines
5.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/workout.dart';
import 'auth_service.dart';
import 'database_service.dart';
/// محرك المزامنة التلقائي
/// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة
class SyncService extends GetxService {
final DatabaseService _db = Get.find<DatabaseService>();
final AuthService _auth = Get.find<AuthService>();
final _connectivity = Connectivity();
StreamSubscription? _connectivitySub;
Timer? _syncTimer;
final _isSyncing = false.obs;
final _isOnline = false.obs;
final _pendingCount = 0.obs;
bool get isSyncing => _isSyncing.value;
bool get isOnline => _isOnline.value;
int get pendingCount => _pendingCount.value;
Future<SyncService> init() async {
await _checkConnectivity();
_startConnectivityMonitor();
_startPeriodicSync();
await _updatePendingCount();
return this;
}
// ─── Connectivity Monitoring ──────────────────────────────────
Future<void> _checkConnectivity() async {
final results = await _connectivity.checkConnectivity();
_isOnline.value = !results.contains(ConnectivityResult.none);
}
void _startConnectivityMonitor() {
_connectivitySub = _connectivity.onConnectivityChanged.listen(
(List<ConnectivityResult> results) {
final wasOffline = !_isOnline.value;
_isOnline.value = !results.contains(ConnectivityResult.none);
// Came back online → trigger sync
if (wasOffline && _isOnline.value) {
debugPrint('[SYNC] Back online — triggering sync');
syncPendingWorkouts();
}
},
);
}
void _startPeriodicSync() {
// Check for pending syncs every 2 minutes
_syncTimer = Timer.periodic(
const Duration(minutes: 2),
(_) {
if (_isOnline.value && !_isSyncing.value) {
syncPendingWorkouts();
}
},
);
}
// ─── Sync Operations ─────────────────────────────────────────
/// Queue a workout for sync
Future<void> queueWorkout(Workout workout) async {
final payload = jsonEncode(workout.toApiPayload());
final queueForSync = workout.coordinates.length >= 2 &&
workout.routePolyline != null &&
workout.routePolyline!.isNotEmpty;
await _db.saveCompletedWorkout(workout, payload,
queueForSync: queueForSync);
await _updatePendingCount();
// Try immediate sync if online
if (queueForSync && _isOnline.value) {
syncPendingWorkouts();
}
}
Future<void> retryWorkout(String workoutId) async {
await _db.retryFailedSync(workoutId);
await _updatePendingCount();
if (_isOnline.value && !_isSyncing.value) {
await syncPendingWorkouts();
}
}
/// Process all pending sync items
Future<void> syncPendingWorkouts({bool retryNow = false}) async {
if (_isSyncing.value) return;
_isSyncing.value = true;
try {
if (!_auth.isSignedIn) {
debugPrint('[SYNC] Sign in to sync saved workouts');
return;
}
final pendingItems = await _db.getPendingSyncs(
includeExhausted: retryNow,
);
if (pendingItems.isEmpty) return;
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
for (final item in pendingItems) {
if (!retryNow && !_isRetryDue(item)) continue;
await _syncSingleItem(
id: item['id'] as int,
workoutId: item['workout_id'] as String,
payload: item['payload'] as String,
);
}
await _db.removeSentFromQueue();
await _updatePendingCount();
} catch (e) {
debugPrint('[SYNC] Error during sync: $e');
} finally {
_isSyncing.value = false;
}
}
Future<void> _syncSingleItem({
required int id,
required String workoutId,
required String payload,
}) async {
try {
final response = await _auth.authenticatedPost(
Uri.parse('${_auth.apiBase}/workouts.php'),
payload,
);
if (response.statusCode == 200 || response.statusCode == 201) {
await _db.updateSyncQueueItem(id, 'sent');
await _db.updateSyncStatus(
workoutId,
SyncStatus.synced,
syncedAt: DateTime.now(),
);
debugPrint('[SYNC] ✅ Workout $workoutId synced');
} else {
await _db.updateSyncQueueItem(
id,
'failed',
errorMessage: 'HTTP ${response.statusCode}: ${response.body}',
);
debugPrint('[SYNC] ❌ Failed: ${response.statusCode}');
}
} catch (e) {
await _db.updateSyncQueueItem(id, 'failed', errorMessage: e.toString());
debugPrint('[SYNC] ❌ Error: $e');
}
}
Future<void> _updatePendingCount() async {
_pendingCount.value = await _db.getPendingSyncCount();
}
bool _isRetryDue(Map<String, dynamic> item) {
if (item['status'] != 'failed') return true;
final lastAttempt = item['last_attempt'] as String?;
if (lastAttempt == null) return true;
final retryCount = item['retry_count'] as int? ?? 0;
final delaySeconds = (30 * (1 << retryCount)).clamp(30, 3600);
final attemptedAt = DateTime.tryParse(lastAttempt);
if (attemptedAt == null) return true;
return DateTime.now().toUtc().difference(attemptedAt.toUtc()).inSeconds >=
delaySeconds;
}
@override
void onClose() {
_connectivitySub?.cancel();
_syncTimer?.cancel();
super.onClose();
}
}