282 lines
8.8 KiB
Dart
282 lines
8.8 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 '../models/meal_entry.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,
|
|
bool pullMeals = false,
|
|
}) async {
|
|
if (_isSyncing.value) return;
|
|
_isSyncing.value = true;
|
|
|
|
try {
|
|
if (!_auth.isSignedIn) {
|
|
debugPrint('[SYNC] Sign in to sync saved workouts');
|
|
return;
|
|
}
|
|
|
|
await _syncPendingMeals();
|
|
|
|
final pendingItems = await _db.getPendingSyncs(
|
|
includeExhausted: retryNow,
|
|
);
|
|
if (pendingItems.isNotEmpty) {
|
|
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();
|
|
|
|
if (pullMeals) {
|
|
try {
|
|
await _pullRecentMeals();
|
|
} catch (error) {
|
|
debugPrint('[SYNC] Meal history refresh failed: $error');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[SYNC] Error during sync: $e');
|
|
} finally {
|
|
_isSyncing.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> queueMeal(MealEntry meal) async {
|
|
meal.syncStatus = MealSyncStatus.pending;
|
|
await _db.saveMeal(meal);
|
|
await _updatePendingCount();
|
|
if (_isOnline.value && _auth.isSignedIn && !_isSyncing.value) {
|
|
await syncPendingWorkouts();
|
|
}
|
|
}
|
|
|
|
Future<void> syncMealsNow() async {
|
|
if (!_isOnline.value || !_auth.isSignedIn || _isSyncing.value) return;
|
|
await syncPendingWorkouts(retryNow: true, pullMeals: true);
|
|
}
|
|
|
|
Future<void> _syncPendingMeals() async {
|
|
final pendingMeals = await _db.getPendingMeals();
|
|
for (final meal in pendingMeals) {
|
|
try {
|
|
final response = await _auth.authenticatedPost(
|
|
Uri.parse('${_auth.apiBase}/meals.php'),
|
|
jsonEncode(meal.toApiPayload()),
|
|
);
|
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
|
await _db.updateMealSyncStatus(meal.id, MealSyncStatus.synced);
|
|
} else {
|
|
await _db.updateMealSyncStatus(
|
|
meal.id,
|
|
MealSyncStatus.failed,
|
|
error: 'HTTP ${response.statusCode}',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
await _db.updateMealSyncStatus(
|
|
meal.id,
|
|
MealSyncStatus.failed,
|
|
error: error.toString(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _pullRecentMeals() async {
|
|
final now = DateTime.now().toUtc();
|
|
final from = now.subtract(const Duration(days: 30));
|
|
final to = now.add(const Duration(days: 1));
|
|
final uri = Uri.parse('${_auth.apiBase}/meals.php').replace(
|
|
queryParameters: {
|
|
'from':
|
|
'${from.year.toString().padLeft(4, '0')}-${from.month.toString().padLeft(2, '0')}-${from.day.toString().padLeft(2, '0')}',
|
|
'to':
|
|
'${to.year.toString().padLeft(4, '0')}-${to.month.toString().padLeft(2, '0')}-${to.day.toString().padLeft(2, '0')}',
|
|
},
|
|
);
|
|
final response = await _auth.authenticatedGet(uri);
|
|
if (response.statusCode != 200) {
|
|
throw AuthException('تعذر جلب سجل الطعام (${response.statusCode}).');
|
|
}
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! Map<String, dynamic> || decoded['meals'] is! List) return;
|
|
for (final raw in decoded['meals'] as List) {
|
|
if (raw is! Map<String, dynamic>) continue;
|
|
final local = <String, dynamic>{
|
|
'id': raw['client_meal_uuid'],
|
|
'name': raw['name'],
|
|
'meal_type': raw['meal_type'],
|
|
'calories': raw['calories'],
|
|
'protein_grams': raw['protein_grams'],
|
|
'carbohydrate_grams': raw['carbohydrate_grams'],
|
|
'fat_grams': raw['fat_grams'],
|
|
'consumed_at': raw['consumed_at'],
|
|
'source': raw['source'],
|
|
'notes': raw['notes'],
|
|
'sync_status': MealSyncStatus.synced.name,
|
|
};
|
|
await _db.mergeSyncedMeal(MealEntry.fromMap(local));
|
|
}
|
|
}
|
|
|
|
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() + await _db.getPendingMealCount();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|