Prepare secure config and reliable workout sync
This commit is contained in:
@@ -97,14 +97,7 @@ class WorkoutController extends GetxController {
|
||||
workout.endLng = workout.coordinates.last.longitude;
|
||||
}
|
||||
|
||||
// حفظ في قاعدة البيانات المحلية
|
||||
await _db.saveWorkout(workout);
|
||||
await _db.saveSegments(workout.id, workout.segments);
|
||||
|
||||
// مسح الإحداثيات المؤقتة من جدول الـ GPS (لأنها حفظت في الـ Polyline)
|
||||
// أو يمكن تركها إذا أردت دقة عالية جداً لاحقاً
|
||||
|
||||
// إضافة إلى طابور المزامنة
|
||||
// Save the workout and its outbox record in one local transaction.
|
||||
await _sync.queueWorkout(workout);
|
||||
|
||||
currentWorkout.value = null;
|
||||
|
||||
@@ -12,10 +12,9 @@ void main() async {
|
||||
|
||||
// 1. تهيئة الخدمات (Services Initialization)
|
||||
// بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل
|
||||
await Get.putAsync(
|
||||
() => DatabaseService().onInit().then((_) => DatabaseService()));
|
||||
await Get.putAsync<DatabaseService>(() => DatabaseService().init());
|
||||
Get.put(GpsService());
|
||||
await Get.putAsync(() => SyncService().onInit().then((_) => SyncService()));
|
||||
await Get.putAsync<SyncService>(() => SyncService().init());
|
||||
Get.put(AnalyticsService());
|
||||
|
||||
// 2. تهيئة المتحكمات (Controllers)
|
||||
|
||||
@@ -245,6 +245,7 @@ class Workout {
|
||||
/// Convert to API payload for server submission
|
||||
Map<String, dynamic> toApiPayload() {
|
||||
return {
|
||||
'client_workout_id': id,
|
||||
'workout_type': workoutType.name,
|
||||
'distance_meters': distanceMeters.toInt(),
|
||||
'duration_seconds': durationSeconds,
|
||||
|
||||
@@ -10,16 +10,15 @@ import '../models/workout_segment.dart';
|
||||
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
|
||||
class DatabaseService extends GetxService {
|
||||
static const String _dbName = 'sportpath_tracker.db';
|
||||
static const int _dbVersion = 2;
|
||||
static const int _dbVersion = 3;
|
||||
|
||||
late Database _db;
|
||||
|
||||
Database get db => _db;
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
Future<DatabaseService> init() async {
|
||||
await _initDatabase();
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<void> _initDatabase() async {
|
||||
@@ -28,6 +27,9 @@ class DatabaseService extends GetxService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: _dbVersion,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
@@ -136,6 +138,9 @@ class DatabaseService extends GetxService {
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_sync_status ON sync_queue(status)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
|
||||
debugPrint('[DB] All tables created successfully');
|
||||
}
|
||||
@@ -155,6 +160,11 @@ class DatabaseService extends GetxService {
|
||||
// Columns may already exist
|
||||
}
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_sync_workout ON sync_queue(workout_id)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GPS Coordinates CRUD ─────────────────────────────────────
|
||||
@@ -234,6 +244,73 @@ class DatabaseService extends GetxService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Atomically persist a finished workout, its segments, and its sync outbox item.
|
||||
Future<void> saveCompletedWorkout(Workout workout, String payload) async {
|
||||
await _db.transaction((txn) async {
|
||||
final saved = await txn.query(
|
||||
'workouts',
|
||||
columns: ['id'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [workout.id],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (saved.isEmpty) {
|
||||
await txn.insert('workouts', workout.toMap());
|
||||
} else {
|
||||
final existingWorkout = await txn.query(
|
||||
'workouts',
|
||||
columns: ['sync_status', 'synced_at'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [workout.id],
|
||||
limit: 1,
|
||||
);
|
||||
final values = workout.toMap()
|
||||
..['sync_status'] = existingWorkout.first['sync_status']
|
||||
..['synced_at'] = existingWorkout.first['synced_at'];
|
||||
await txn.update(
|
||||
'workouts',
|
||||
values,
|
||||
where: 'id = ?',
|
||||
whereArgs: [workout.id],
|
||||
);
|
||||
}
|
||||
|
||||
await txn.delete(
|
||||
'workout_segments',
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workout.id],
|
||||
);
|
||||
for (final segment in workout.segments) {
|
||||
final values = segment.toMap()..['workout_id'] = workout.id;
|
||||
await txn.insert('workout_segments', values);
|
||||
}
|
||||
|
||||
final queued = await txn.query(
|
||||
'sync_queue',
|
||||
columns: ['id', 'status'],
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workout.id],
|
||||
orderBy: 'id ASC',
|
||||
limit: 1,
|
||||
);
|
||||
if (queued.isEmpty) {
|
||||
await txn.insert('sync_queue', {
|
||||
'workout_id': workout.id,
|
||||
'payload': payload,
|
||||
'status': 'queued',
|
||||
});
|
||||
} else if (queued.first['status'] != 'sent') {
|
||||
await txn.update(
|
||||
'sync_queue',
|
||||
{'payload': payload},
|
||||
where: 'id = ?',
|
||||
whereArgs: [queued.first['id']],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update workout fields
|
||||
Future<void> updateWorkout(Workout workout) async {
|
||||
await _db.update(
|
||||
@@ -369,33 +446,49 @@ class DatabaseService extends GetxService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getPendingSyncCount() async {
|
||||
final result = await _db.rawQuery(
|
||||
"SELECT COUNT(*) AS cnt FROM sync_queue WHERE status IN ('queued', 'failed')",
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
Future<void> retryFailedSync(String workoutId) async {
|
||||
await _db.update(
|
||||
'sync_queue',
|
||||
{
|
||||
'status': 'queued',
|
||||
'retry_count': 0,
|
||||
'last_attempt': null,
|
||||
'error_message': null,
|
||||
},
|
||||
where: "workout_id = ? AND status = 'failed'",
|
||||
whereArgs: [workoutId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Update sync queue item status
|
||||
Future<void> updateSyncQueueItem(
|
||||
int id,
|
||||
String status, {
|
||||
String? errorMessage,
|
||||
}) async {
|
||||
int newRetryCount = 0;
|
||||
|
||||
// سحبنا المنطق خارج الـ Map ليكون واضحاً ومقروءاً
|
||||
if (status == 'failed') {
|
||||
final currentItem =
|
||||
await _db.query('sync_queue', where: 'id = ?', whereArgs: [id]);
|
||||
if (currentItem.isNotEmpty) {
|
||||
newRetryCount = (currentItem.first['retry_count'] as int? ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'sync_queue',
|
||||
{
|
||||
'status': status,
|
||||
'last_attempt': DateTime.now().toIso8601String(),
|
||||
'retry_count': newRetryCount, // وضعنا القيمة الجاهزة هنا
|
||||
if (errorMessage != null) 'error_message': errorMessage,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
await _db.rawUpdate(
|
||||
'''
|
||||
UPDATE sync_queue
|
||||
SET status = ?,
|
||||
last_attempt = ?,
|
||||
retry_count = retry_count + CASE WHEN ? = 'failed' THEN 1 ELSE 0 END,
|
||||
error_message = ?
|
||||
WHERE id = ?
|
||||
''',
|
||||
[
|
||||
status,
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
status,
|
||||
errorMessage,
|
||||
id,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,12 @@ class SyncService extends GetxService {
|
||||
static const String _apiKeyKey = 'api_key';
|
||||
static const String _apiSecretKey = 'api_secret';
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
Future<SyncService> init() async {
|
||||
await _checkConnectivity();
|
||||
_startConnectivityMonitor();
|
||||
_startPeriodicSync();
|
||||
await _updatePendingCount();
|
||||
return this;
|
||||
}
|
||||
|
||||
// ─── Connectivity Monitoring ──────────────────────────────────
|
||||
@@ -107,7 +106,7 @@ class SyncService extends GetxService {
|
||||
/// Queue a workout for sync
|
||||
Future<void> queueWorkout(Workout workout) async {
|
||||
final payload = jsonEncode(workout.toApiPayload());
|
||||
await _db.enqueueSync(workout.id, payload);
|
||||
await _db.saveCompletedWorkout(workout, payload);
|
||||
await _updatePendingCount();
|
||||
|
||||
// Try immediate sync if online
|
||||
@@ -116,6 +115,14 @@ class SyncService extends GetxService {
|
||||
}
|
||||
}
|
||||
|
||||
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() async {
|
||||
if (_isSyncing.value) return;
|
||||
@@ -135,6 +142,7 @@ class SyncService extends GetxService {
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
for (final item in pendingItems) {
|
||||
if (!_isRetryDue(item)) continue;
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
@@ -177,7 +185,7 @@ class SyncService extends GetxService {
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
await _db.updateSyncQueueItem(id, 'sent');
|
||||
await _db.updateSyncStatus(
|
||||
workoutId,
|
||||
@@ -200,8 +208,20 @@ class SyncService extends GetxService {
|
||||
}
|
||||
|
||||
Future<void> _updatePendingCount() async {
|
||||
final items = await _db.getPendingSyncs();
|
||||
_pendingCount.value = items.length;
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user