483 lines
14 KiB
Dart
483 lines
14 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';
|
|
|
|
/// خدمة قاعدة البيانات المحلية
|
|
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
|
|
class DatabaseService extends GetxService {
|
|
static const String _dbName = 'sportpath_tracker.db';
|
|
static const int _dbVersion = 2;
|
|
|
|
late Database _db;
|
|
|
|
Database get db => _db;
|
|
|
|
@override
|
|
Future<void> onInit() async {
|
|
super.onInit();
|
|
await _initDatabase();
|
|
}
|
|
|
|
Future<void> _initDatabase() async {
|
|
try {
|
|
final dbPath = join(await getDatabasesPath(), _dbName);
|
|
_db = await openDatabase(
|
|
dbPath,
|
|
version: _dbVersion,
|
|
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)',
|
|
);
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 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,
|
|
);
|
|
}
|
|
|
|
/// 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() async {
|
|
return await _db.query(
|
|
'sync_queue',
|
|
where: "status IN ('queued', 'failed') AND retry_count < 5",
|
|
orderBy: 'created_at ASC',
|
|
);
|
|
}
|
|
|
|
/// 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],
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_db.close();
|
|
super.onClose();
|
|
}
|
|
}
|