import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:sqflite/sqflite.dart'; 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 = 4; late Database _db; Database get db => _db; Future init() async { await _initDatabase(); return this; } Future _initDatabase() async { try { final dbPath = join(await getDatabasesPath(), _dbName); _db = await openDatabase( dbPath, version: _dbVersion, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); }, onCreate: _onCreate, onUpgrade: _onUpgrade, ); debugPrint('[DB] Database initialized at $dbPath'); } catch (e) { debugPrint('[DB] ERROR initializing database: $e'); rethrow; } } Future _onCreate(Database db, int version) async { debugPrint('[DB] Creating all tables (v$version)...'); // GPS Coordinates table — stores raw tracking data await db.execute(''' CREATE TABLE gps_coordinates ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id TEXT NOT NULL, latitude REAL NOT NULL, longitude REAL NOT NULL, altitude REAL, accuracy REAL, speed REAL, heading REAL, timestamp TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ) '''); await db.execute( 'CREATE INDEX idx_coords_workout ON gps_coordinates(workout_id)', ); await db.execute( 'CREATE INDEX idx_coords_timestamp ON gps_coordinates(workout_id, timestamp)', ); // Workouts table — stores completed workout summaries await db.execute(''' CREATE TABLE workouts ( id TEXT PRIMARY KEY, workout_type TEXT NOT NULL DEFAULT 'running', start_time TEXT, end_time TEXT, distance_meters REAL DEFAULT 0, duration_seconds INTEGER DEFAULT 0, elevation_gain REAL DEFAULT 0, elevation_loss REAL DEFAULT 0, calories REAL DEFAULT 0, avg_speed_mps REAL DEFAULT 0, max_speed_mps REAL DEFAULT 0, avg_pace_min_km REAL DEFAULT 0, route_polyline TEXT, start_lat REAL, start_lng REAL, end_lat REAL, end_lng REAL, weather TEXT, temperature REAL, notes TEXT DEFAULT '', sync_status TEXT DEFAULT 'pending', synced_at TEXT, created_at TEXT DEFAULT (datetime('now')) ) '''); await db.execute( 'CREATE INDEX idx_workouts_sync ON workouts(sync_status)', ); await db.execute( 'CREATE INDEX idx_workouts_created ON workouts(created_at DESC)', ); // Workout segments table — auto-detected route segments await db.execute(''' CREATE TABLE workout_segments ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id TEXT NOT NULL, segment_index INTEGER NOT NULL, segment_type TEXT NOT NULL, distance_meters REAL DEFAULT 0, duration_seconds INTEGER DEFAULT 0, avg_speed_mps REAL DEFAULT 0, max_speed_mps REAL DEFAULT 0, elevation_change REAL DEFAULT 0, start_coord_index INTEGER DEFAULT 0, end_coord_index INTEGER DEFAULT 0, FOREIGN KEY (workout_id) REFERENCES workouts(id) ON DELETE CASCADE ) '''); await db.execute( 'CREATE INDEX idx_segments_workout ON workout_segments(workout_id)', ); // Sync queue — tracks pending uploads await db.execute(''' CREATE TABLE sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, workout_id TEXT NOT NULL, payload TEXT NOT NULL, retry_count INTEGER DEFAULT 0, last_attempt TEXT, status TEXT DEFAULT 'queued', error_message TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (workout_id) REFERENCES workouts(id) ON DELETE CASCADE ) '''); 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)', ); await _createMealTable(db); debugPrint('[DB] All tables created successfully'); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { debugPrint('[DB] Upgrading from v$oldVersion to v$newVersion'); if (oldVersion < 2) { // Add speed and heading columns to existing coordinates table try { await db.execute( 'ALTER TABLE gps_coordinates ADD COLUMN speed REAL', ); await db.execute( 'ALTER TABLE gps_coordinates ADD COLUMN heading REAL', ); } catch (_) { // Columns may already exist } } if (oldVersion < 3) { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_sync_workout ON sync_queue(workout_id)', ); } if (oldVersion < 4) await _createMealTable(db); } Future _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 ───────────────────────────────────── /// Save a single coordinate during active tracking Future saveCoordinate(String workoutId, GpsCoordinate coord) async { await _db.insert('gps_coordinates', { 'workout_id': workoutId, 'latitude': coord.latitude, 'longitude': coord.longitude, 'altitude': coord.altitude, 'accuracy': coord.accuracy, 'speed': coord.speed, 'heading': coord.heading, 'timestamp': coord.timestamp.toIso8601String(), }); } /// Batch save coordinates (more efficient for large imports) Future saveCoordinatesBatch( String workoutId, List coords, ) async { final batch = _db.batch(); for (final coord in coords) { batch.insert('gps_coordinates', { 'workout_id': workoutId, 'latitude': coord.latitude, 'longitude': coord.longitude, 'altitude': coord.altitude, 'accuracy': coord.accuracy, 'speed': coord.speed, 'heading': coord.heading, 'timestamp': coord.timestamp.toIso8601String(), }); } await batch.commit(noResult: true); } /// Retrieve all coordinates for a workout Future> getCoordinates(String workoutId) async { final rows = await _db.query( 'gps_coordinates', where: 'workout_id = ?', whereArgs: [workoutId], orderBy: 'timestamp ASC', ); return rows.map((r) => GpsCoordinate.fromMap(r)).toList(); } /// Get coordinate count for a workout Future getCoordinateCount(String workoutId) async { final result = await _db.rawQuery( 'SELECT COUNT(*) as cnt FROM gps_coordinates WHERE workout_id = ?', [workoutId], ); return Sqflite.firstIntValue(result) ?? 0; } /// Delete coordinates for a workout (after successful sync) Future deleteCoordinates(String workoutId) async { await _db.delete( 'gps_coordinates', where: 'workout_id = ?', whereArgs: [workoutId], ); } // ─── Workouts CRUD ──────────────────────────────────────────── /// Save a completed workout Future saveWorkout(Workout workout) async { await _db.insert( 'workouts', workout.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } /// Atomically persist a finished workout, its segments, and its sync outbox item. Future saveCompletedWorkout( Workout workout, String payload, { bool queueForSync = true, }) 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); } if (!queueForSync) return; 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 updateWorkout(Workout workout) async { await _db.update( 'workouts', workout.toMap(), where: 'id = ?', whereArgs: [workout.id], ); } /// Get a single workout by ID Future getWorkout(String id) async { final rows = await _db.query( 'workouts', where: 'id = ?', whereArgs: [id], ); if (rows.isEmpty) return null; return Workout.fromMap(rows.first); } /// Get all workouts ordered by date Future> getAllWorkouts({int? limit, int? offset}) async { final rows = await _db.query( 'workouts', orderBy: 'created_at DESC', limit: limit, offset: offset, ); return rows.map((r) => Workout.fromMap(r)).toList(); } /// Get workouts by sync status Future> getWorkoutsBySync(SyncStatus status) async { final rows = await _db.query( 'workouts', where: 'sync_status = ?', whereArgs: [status.name], orderBy: 'created_at ASC', ); return rows.map((r) => Workout.fromMap(r)).toList(); } /// Update sync status for a workout Future updateSyncStatus( String workoutId, SyncStatus status, { DateTime? syncedAt, }) async { final updates = { 'sync_status': status.name, }; if (syncedAt != null) { updates['synced_at'] = syncedAt.toIso8601String(); } await _db.update( 'workouts', updates, where: 'id = ?', whereArgs: [workoutId], ); } /// Delete a workout and its related data Future deleteWorkout(String id) async { await _db.transaction((txn) async { await txn .delete('workout_segments', where: 'workout_id = ?', whereArgs: [id]); await txn .delete('gps_coordinates', where: 'workout_id = ?', whereArgs: [id]); await txn.delete('sync_queue', where: 'workout_id = ?', whereArgs: [id]); await txn.delete('workouts', where: 'id = ?', whereArgs: [id]); }); } /// Get total workout count Future getWorkoutCount() async { final result = await _db.rawQuery('SELECT COUNT(*) as cnt FROM workouts'); return Sqflite.firstIntValue(result) ?? 0; } // ─── Workout Segments ───────────────────────────────────────── /// Save segments for a workout Future saveSegments( String workoutId, List segments, ) async { // Delete existing segments first await _db.delete( 'workout_segments', where: 'workout_id = ?', whereArgs: [workoutId], ); final batch = _db.batch(); for (final seg in segments) { final map = seg.toMap(); map['workout_id'] = workoutId; batch.insert('workout_segments', map); } await batch.commit(noResult: true); } /// Get segments for a workout Future> getSegments(String workoutId) async { final rows = await _db.query( 'workout_segments', where: 'workout_id = ?', whereArgs: [workoutId], orderBy: 'segment_index ASC', ); return rows.map((r) => WorkoutSegment.fromMap(r)).toList(); } // ─── Sync Queue ─────────────────────────────────────────────── /// Add workout to sync queue Future enqueueSync(String workoutId, String payload) async { await _db.insert('sync_queue', { 'workout_id': workoutId, 'payload': payload, 'status': 'queued', }); } /// Get all queued items Future>> getPendingSyncs( {bool includeExhausted = false}) async { return await _db.query( 'sync_queue', where: includeExhausted ? "status IN ('queued', 'failed')" : "status IN ('queued', 'failed') AND retry_count < 5", orderBy: 'created_at ASC', ); } Future 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 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 updateSyncQueueItem( int id, String status, { String? errorMessage, }) async { 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, ], ); } /// Remove sent items from queue Future removeSentFromQueue() async { await _db.delete('sync_queue', where: "status = 'sent'"); } // ─── Statistics Queries ─────────────────────────────────────── /// Get aggregate statistics Future> getAggregateStats() async { final result = await _db.rawQuery(''' SELECT COUNT(*) as total_workouts, COALESCE(SUM(distance_meters), 0) as total_distance, COALESCE(SUM(duration_seconds), 0) as total_duration, COALESCE(SUM(calories), 0) as total_calories, COALESCE(AVG(avg_speed_mps), 0) as avg_speed, COALESCE(MAX(max_speed_mps), 0) as top_speed, COALESCE(SUM(elevation_gain), 0) as total_elevation_gain FROM workouts '''); return result.first; } /// Get workouts for a date range (for weekly/monthly reports) Future> getWorkoutsInRange( DateTime start, DateTime end, ) async { final rows = await _db.query( 'workouts', where: 'created_at >= ? AND created_at <= ?', whereArgs: [start.toIso8601String(), end.toIso8601String()], orderBy: 'created_at ASC', ); return rows.map((r) => Workout.fromMap(r)).toList(); } /// Get weekly summary Future>> getWeeklySummary(int weeks) async { return await _db.rawQuery(''' SELECT strftime('%Y-%W', created_at) as week, COUNT(*) as count, SUM(distance_meters) as distance, SUM(duration_seconds) as duration, AVG(avg_speed_mps) as avg_speed FROM workouts WHERE created_at >= datetime('now', '-$weeks weeks') GROUP BY week ORDER BY week DESC '''); } /// Get best time of day for performance Future?> getBestTimeOfDay() async { final result = await _db.rawQuery(''' SELECT CASE WHEN CAST(strftime('%H', start_time) AS INTEGER) < 6 THEN 'night' WHEN CAST(strftime('%H', start_time) AS INTEGER) < 12 THEN 'morning' WHEN CAST(strftime('%H', start_time) AS INTEGER) < 18 THEN 'afternoon' ELSE 'evening' END as time_period, AVG(avg_speed_mps) as avg_speed, COUNT(*) as count FROM workouts WHERE start_time IS NOT NULL GROUP BY time_period HAVING count >= 2 ORDER BY avg_speed DESC LIMIT 1 '''); return result.isNotEmpty ? result.first : null; } Future saveMeal(MealEntry meal) async { await _db.insert( 'meal_entries', {...meal.toMap(), 'updated_at': DateTime.now().toUtc().toIso8601String()}, conflictAlgorithm: ConflictAlgorithm.replace, ); } Future 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> 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> 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 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 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 getMealCountForDay(DateTime day) async => (await getMealsForDay(day)).length; @override void onClose() { _db.close(); super.onClose(); } }