first commit
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user