Files
fitness/mobile/lib/services/database_service.dart
T

689 lines
21 KiB
Dart

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<DatabaseService> init() async {
await _initDatabase();
return this;
}
Future<void> _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<void> _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<void> _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<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 ─────────────────────────────────────
/// Save a single coordinate during active tracking
Future<void> 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<void> saveCoordinatesBatch(
String workoutId,
List<GpsCoordinate> 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<List<GpsCoordinate>> 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<int> 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<void> deleteCoordinates(String workoutId) async {
await _db.delete(
'gps_coordinates',
where: 'workout_id = ?',
whereArgs: [workoutId],
);
}
// ─── Workouts CRUD ────────────────────────────────────────────
/// Save a completed workout
Future<void> 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<void> 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<void> updateWorkout(Workout workout) async {
await _db.update(
'workouts',
workout.toMap(),
where: 'id = ?',
whereArgs: [workout.id],
);
}
/// Get a single workout by ID
Future<Workout?> 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<List<Workout>> 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<List<Workout>> 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<void> updateSyncStatus(
String workoutId,
SyncStatus status, {
DateTime? syncedAt,
}) async {
final updates = <String, dynamic>{
'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<void> 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<int> 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<void> saveSegments(
String workoutId,
List<WorkoutSegment> 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<List<WorkoutSegment>> 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<void> enqueueSync(String workoutId, String payload) async {
await _db.insert('sync_queue', {
'workout_id': workoutId,
'payload': payload,
'status': 'queued',
});
}
/// Get all queued items
Future<List<Map<String, dynamic>>> 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<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 {
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<void> removeSentFromQueue() async {
await _db.delete('sync_queue', where: "status = 'sent'");
}
// ─── Statistics Queries ───────────────────────────────────────
/// Get aggregate statistics
Future<Map<String, dynamic>> 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<List<Workout>> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> 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<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();
super.onClose();
}
}