Add offline meal journal and secure sync
This commit is contained in:
@@ -119,6 +119,23 @@ class AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<http.Response> authenticatedGet(Uri uri) async {
|
||||
_requireApi();
|
||||
_accessToken ??= await _storage.read(key: _accessKey);
|
||||
if (_accessToken == null && !await _refreshSession()) {
|
||||
throw const AuthException('سجّل الدخول لمزامنة بياناتك.');
|
||||
}
|
||||
var response = await _client
|
||||
.get(uri, headers: _bearerHeaders(_accessToken!))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.get(uri, headers: _bearerHeaders(_accessToken!))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<bool> _refreshSession() async {
|
||||
final activeRefresh = _refreshing;
|
||||
if (activeRefresh != null) return activeRefresh;
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:path/path.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/workout_segment.dart';
|
||||
import '../models/meal_entry.dart';
|
||||
|
||||
/// خدمة قاعدة البيانات المحلية
|
||||
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
|
||||
class DatabaseService extends GetxService {
|
||||
static const String _dbName = 'sportpath_tracker.db';
|
||||
static const int _dbVersion = 3;
|
||||
static const int _dbVersion = 4;
|
||||
|
||||
late Database _db;
|
||||
|
||||
@@ -142,6 +143,8 @@ class DatabaseService extends GetxService {
|
||||
'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
|
||||
await _createMealTable(db);
|
||||
|
||||
debugPrint('[DB] All tables created successfully');
|
||||
}
|
||||
|
||||
@@ -165,6 +168,33 @@ class DatabaseService extends GetxService {
|
||||
'CREATE INDEX IF NOT EXISTS idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 4) await _createMealTable(db);
|
||||
}
|
||||
|
||||
Future<void> _createMealTable(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS meal_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL CHECK(meal_type IN ('breakfast','lunch','dinner','snack')),
|
||||
calories REAL NOT NULL CHECK(calories >= 0 AND calories <= 10000),
|
||||
protein_grams REAL NOT NULL DEFAULT 0 CHECK(protein_grams >= 0 AND protein_grams <= 1000),
|
||||
carbohydrate_grams REAL NOT NULL DEFAULT 0 CHECK(carbohydrate_grams >= 0 AND carbohydrate_grams <= 1000),
|
||||
fat_grams REAL NOT NULL DEFAULT 0 CHECK(fat_grams >= 0 AND fat_grams <= 1000),
|
||||
consumed_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK(source IN ('manual','photo_ai')),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending' CHECK(sync_status IN ('pending','synced','failed')),
|
||||
last_sync_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_meals_consumed_at ON meal_entries(consumed_at DESC)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meal_entries(sync_status, consumed_at)',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── GPS Coordinates CRUD ─────────────────────────────────────
|
||||
@@ -576,6 +606,80 @@ class DatabaseService extends GetxService {
|
||||
return result.isNotEmpty ? result.first : null;
|
||||
}
|
||||
|
||||
Future<void> saveMeal(MealEntry meal) async {
|
||||
await _db.insert(
|
||||
'meal_entries',
|
||||
{...meal.toMap(), 'updated_at': DateTime.now().toUtc().toIso8601String()},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> mergeSyncedMeal(MealEntry meal) async {
|
||||
final local = await _db.query(
|
||||
'meal_entries',
|
||||
columns: ['sync_status'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [meal.id],
|
||||
limit: 1,
|
||||
);
|
||||
if (local.isNotEmpty &&
|
||||
(local.first['sync_status'] == MealSyncStatus.pending.name ||
|
||||
local.first['sync_status'] == MealSyncStatus.failed.name)) {
|
||||
return;
|
||||
}
|
||||
meal.syncStatus = MealSyncStatus.synced;
|
||||
await saveMeal(meal);
|
||||
}
|
||||
|
||||
Future<List<MealEntry>> getMealsForDay(DateTime day) async {
|
||||
final start = DateTime(day.year, day.month, day.day).toUtc();
|
||||
final end = DateTime(day.year, day.month, day.day + 1).toUtc();
|
||||
final rows = await _db.query(
|
||||
'meal_entries',
|
||||
where: 'consumed_at >= ? AND consumed_at < ?',
|
||||
whereArgs: [start.toIso8601String(), end.toIso8601String()],
|
||||
orderBy: 'consumed_at DESC',
|
||||
);
|
||||
return rows.map(MealEntry.fromMap).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<MealEntry>> getPendingMeals({int limit = 100}) async {
|
||||
final rows = await _db.query(
|
||||
'meal_entries',
|
||||
where: "sync_status IN ('pending','failed')",
|
||||
orderBy: 'consumed_at ASC',
|
||||
limit: limit,
|
||||
);
|
||||
return rows.map(MealEntry.fromMap).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<int> getPendingMealCount() async {
|
||||
final result = await _db.rawQuery(
|
||||
"SELECT COUNT(*) AS total FROM meal_entries WHERE sync_status IN ('pending','failed')",
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateMealSyncStatus(
|
||||
String id,
|
||||
MealSyncStatus status, {
|
||||
String? error,
|
||||
}) async {
|
||||
await _db.update(
|
||||
'meal_entries',
|
||||
{
|
||||
'sync_status': status.name,
|
||||
'last_sync_error': error,
|
||||
'updated_at': DateTime.now().toUtc().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getMealCountForDay(DateTime day) async =>
|
||||
(await getMealsForDay(day)).length;
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_db.close();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user