Add offline meal journal and secure sync

This commit is contained in:
Hamza-Ayed
2026-10-04 02:13:23 +03:00
parent d892e3d981
commit 27fc2d5eb1
12 changed files with 936 additions and 23 deletions
+106 -13
View File
@@ -4,6 +4,7 @@ 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';
@@ -94,7 +95,10 @@ class SyncService extends GetxService {
}
/// Process all pending sync items
Future<void> syncPendingWorkouts({bool retryNow = false}) async {
Future<void> syncPendingWorkouts({
bool retryNow = false,
bool pullMeals = false,
}) async {
if (_isSyncing.value) return;
_isSyncing.value = true;
@@ -104,24 +108,34 @@ class SyncService extends GetxService {
return;
}
await _syncPendingMeals();
final pendingItems = await _db.getPendingSyncs(
includeExhausted: retryNow,
);
if (pendingItems.isEmpty) return;
if (pendingItems.isNotEmpty) {
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
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,
);
}
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 _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 {
@@ -129,6 +143,84 @@ class SyncService extends GetxService {
}
}
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,
@@ -163,7 +255,8 @@ class SyncService extends GetxService {
}
Future<void> _updatePendingCount() async {
_pendingCount.value = await _db.getPendingSyncCount();
_pendingCount.value =
await _db.getPendingSyncCount() + await _db.getPendingMealCount();
}
bool _isRetryDue(Map<String, dynamic> item) {