first commit
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import 'package:get/get.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/workout_segment.dart';
|
||||
import '../utils/geo_utils.dart';
|
||||
|
||||
/// خدمة التحليل الذكي (Smart Analytics Service)
|
||||
/// مسؤولة عن تقسيم المسار إلى أجزاء، كشف التوقفات، وتحليل أنماط الأداء.
|
||||
class AnalyticsService extends GetxService {
|
||||
|
||||
/// تحليل المسار وتقسيمه إلى أجزاء (Segments)
|
||||
/// يعتمد على تغيرات السرعة، الارتفاع، أو الاتجاه.
|
||||
List<WorkoutSegment> analyzeSegments(List<GpsCoordinate> coords, WorkoutType type) {
|
||||
if (coords.length < 5) return [];
|
||||
|
||||
List<WorkoutSegment> segments = [];
|
||||
int startIdx = 0;
|
||||
int segmentCounter = 0;
|
||||
|
||||
// 1. تحديد مرحلة الإحماء (Warm-up)
|
||||
// نعتبر أول 10% من النقاط أو أول 500 متر إحماء إذا كانت السرعة متدرجة
|
||||
int warmupEnd = (coords.length * 0.1).toInt().clamp(5, 50);
|
||||
segments.add(_createSegment(coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup));
|
||||
startIdx = warmupEnd;
|
||||
|
||||
// 2. تحليل النقاط الوسطى لكشف التوقفات أو التسارعات
|
||||
for (int i = startIdx + 1; i < coords.length - 5; i++) {
|
||||
final current = coords[i];
|
||||
final prev = coords[i - 1];
|
||||
|
||||
// كشف التوقف (Stop detection)
|
||||
// إذا كانت السرعة شبه صفرية لمسافة معينة
|
||||
if (current.speed != null && current.speed! < 0.3) {
|
||||
int stopEnd = i;
|
||||
while (stopEnd < coords.length - 1 &&
|
||||
(coords[stopEnd].speed ?? 0) < 0.3 &&
|
||||
stopEnd - i < 20) { // توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة
|
||||
stopEnd++;
|
||||
}
|
||||
|
||||
if (stopEnd - i > 3) { // توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
// إغلاق المقطع السابق إذا كان هناك مسافة
|
||||
if (i > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
segments.add(_createSegment(coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
i = stopEnd;
|
||||
startIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// كشف الميول (Elevation detection)
|
||||
// يمكن التوسع هنا لكشف uphill/downhill
|
||||
}
|
||||
|
||||
// 3. تحديد مرحلة التبريد (Cool-down)
|
||||
int cooldownStart = (coords.length * 0.9).toInt();
|
||||
if (cooldownStart > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(coords, cooldownStart, coords.length - 1, segmentCounter++, SegmentType.cooldown));
|
||||
} else {
|
||||
segments.add(_createSegment(coords, startIdx, coords.length - 1, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
WorkoutSegment _createSegment(List<GpsCoordinate> coords, int start, int end, int index, SegmentType type) {
|
||||
double dist = 0;
|
||||
double maxSpd = 0;
|
||||
double sumSpd = 0;
|
||||
double elevChange = 0;
|
||||
|
||||
for (int i = start + 1; i <= end; i++) {
|
||||
dist += GeoUtils.haversineDistance(
|
||||
coords[i-1].latitude, coords[i-1].longitude,
|
||||
coords[i].latitude, coords[i].longitude
|
||||
);
|
||||
double s = coords[i].speed ?? 0;
|
||||
if (s > maxSpd) maxSpd = s;
|
||||
sumSpd += s;
|
||||
|
||||
if (coords[i].altitude != null && coords[i-1].altitude != null) {
|
||||
elevChange += (coords[i].altitude! - coords[i-1].altitude!);
|
||||
}
|
||||
}
|
||||
|
||||
int duration = coords[end].timestamp.difference(coords[start].timestamp).inSeconds;
|
||||
|
||||
return WorkoutSegment(
|
||||
segmentIndex: index,
|
||||
type: type,
|
||||
distanceMeters: dist,
|
||||
durationSeconds: duration,
|
||||
avgSpeedMps: duration > 0 ? dist / duration : 0,
|
||||
maxSpeedMps: maxSpd,
|
||||
elevationChange: elevChange,
|
||||
startCoordIndex: start,
|
||||
endCoordIndex: end,
|
||||
);
|
||||
}
|
||||
|
||||
/// حساب "التقارير الذكية"
|
||||
/// مثال: "سرعتك في أول 2 كم تحسنت بنسبة 12%"
|
||||
String generateInsight(List<Workout> history) {
|
||||
if (history.length < 2) return "ابدأ تمرينك الأول للحصول على تحليلات!";
|
||||
|
||||
// منطق بسيط للمقارنة بين آخر تمرينين
|
||||
final latest = history[0];
|
||||
final previous = history[1];
|
||||
|
||||
if (latest.avgSpeedMps > previous.avgSpeedMps) {
|
||||
double diff = ((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) * 100;
|
||||
return "أداء رائع! سرعتك المتوسطة تحسنت بنسبة ${diff.toStringAsFixed(1)}% مقارنة بآخر مرة.";
|
||||
}
|
||||
|
||||
return "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
|
||||
/// خدمة GPS الحقيقي مع دعم التتبع المستمر
|
||||
class GpsService extends GetxService {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
final _isTracking = false.obs;
|
||||
final _currentPosition = Rxn<Position>();
|
||||
final _lastError = ''.obs;
|
||||
|
||||
bool get isTracking => _isTracking.value;
|
||||
Position? get currentPosition => _currentPosition.value;
|
||||
String get lastError => _lastError.value;
|
||||
|
||||
Function(GpsCoordinate)? onNewCoordinate;
|
||||
|
||||
Future<bool> checkAndRequestPermissions() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
_lastError.value = 'Location services disabled';
|
||||
return false;
|
||||
}
|
||||
var perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
_lastError.value = 'Permission denied';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (perm == LocationPermission.deniedForever) {
|
||||
_lastError.value = 'Permission permanently denied';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<GpsCoordinate?> getCurrentPosition() async {
|
||||
try {
|
||||
if (!await checkAndRequestPermissions()) return null;
|
||||
final pos = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
timeLimit: const Duration(seconds: 15),
|
||||
);
|
||||
_currentPosition.value = pos;
|
||||
return _toCoord(pos);
|
||||
} catch (e) {
|
||||
_lastError.value = e.toString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> startTracking({int distanceFilter = 5}) async {
|
||||
if (_isTracking.value) return true;
|
||||
if (!await checkAndRequestPermissions()) return false;
|
||||
|
||||
try {
|
||||
late LocationSettings settings;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
settings = AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
intervalDuration: const Duration(seconds: 3),
|
||||
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
||||
notificationText: 'SportPath is tracking your workout',
|
||||
notificationTitle: 'Workout Active',
|
||||
enableWakeLock: true,
|
||||
),
|
||||
);
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
settings = AppleSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
activityType: ActivityType.fitness,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
);
|
||||
} else {
|
||||
settings = LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: settings,
|
||||
).listen(_onPosition, onError: (e) {
|
||||
_lastError.value = e.toString();
|
||||
});
|
||||
|
||||
_isTracking.value = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
_lastError.value = e.toString();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopTracking() async {
|
||||
await _positionSub?.cancel();
|
||||
_positionSub = null;
|
||||
_isTracking.value = false;
|
||||
}
|
||||
|
||||
void _onPosition(Position pos) {
|
||||
_currentPosition.value = pos;
|
||||
final coord = _toCoord(pos);
|
||||
if (coord.isAccurate && coord.isValid) {
|
||||
onNewCoordinate?.call(coord);
|
||||
}
|
||||
}
|
||||
|
||||
GpsCoordinate _toCoord(Position p) => GpsCoordinate(
|
||||
latitude: p.latitude,
|
||||
longitude: p.longitude,
|
||||
altitude: p.altitude,
|
||||
accuracy: p.accuracy,
|
||||
speed: p.speed >= 0 ? p.speed : null,
|
||||
heading: p.heading >= 0 ? p.heading : null,
|
||||
timestamp: p.timestamp,
|
||||
);
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
stopTracking();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/workout.dart';
|
||||
import '../utils/hmac_utility.dart';
|
||||
import 'database_service.dart';
|
||||
|
||||
/// محرك المزامنة التلقائي
|
||||
/// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة
|
||||
class SyncService extends GetxService {
|
||||
final DatabaseService _db = Get.find<DatabaseService>();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final _connectivity = Connectivity();
|
||||
|
||||
StreamSubscription? _connectivitySub;
|
||||
Timer? _syncTimer;
|
||||
|
||||
final _isSyncing = false.obs;
|
||||
final _isOnline = false.obs;
|
||||
final _pendingCount = 0.obs;
|
||||
|
||||
bool get isSyncing => _isSyncing.value;
|
||||
bool get isOnline => _isOnline.value;
|
||||
int get pendingCount => _pendingCount.value;
|
||||
|
||||
static const String _apiEndpointKey = 'api_endpoint';
|
||||
static const String _apiKeyKey = 'api_key';
|
||||
static const String _apiSecretKey = 'api_secret';
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
await _checkConnectivity();
|
||||
_startConnectivityMonitor();
|
||||
_startPeriodicSync();
|
||||
await _updatePendingCount();
|
||||
}
|
||||
|
||||
// ─── Connectivity Monitoring ──────────────────────────────────
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_isOnline.value = !results.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
void _startConnectivityMonitor() {
|
||||
_connectivitySub = _connectivity.onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) {
|
||||
final wasOffline = !_isOnline.value;
|
||||
_isOnline.value = !results.contains(ConnectivityResult.none);
|
||||
|
||||
// Came back online → trigger sync
|
||||
if (wasOffline && _isOnline.value) {
|
||||
debugPrint('[SYNC] Back online — triggering sync');
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startPeriodicSync() {
|
||||
// Check for pending syncs every 2 minutes
|
||||
_syncTimer = Timer.periodic(
|
||||
const Duration(minutes: 2),
|
||||
(_) {
|
||||
if (_isOnline.value && !_isSyncing.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Credentials Management ───────────────────────────────────
|
||||
|
||||
Future<void> saveCredentials({
|
||||
required String apiEndpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
await _storage.write(key: _apiEndpointKey, value: apiEndpoint);
|
||||
await _storage.write(key: _apiKeyKey, value: apiKey);
|
||||
await _storage.write(key: _apiSecretKey, value: apiSecret);
|
||||
}
|
||||
|
||||
Future<Map<String, String?>> getCredentials() async {
|
||||
return {
|
||||
'endpoint': await _storage.read(key: _apiEndpointKey),
|
||||
'apiKey': await _storage.read(key: _apiKeyKey),
|
||||
'apiSecret': await _storage.read(key: _apiSecretKey),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> hasCredentials() async {
|
||||
final creds = await getCredentials();
|
||||
return creds['apiKey'] != null &&
|
||||
creds['apiKey']!.isNotEmpty &&
|
||||
creds['apiSecret'] != null &&
|
||||
creds['apiSecret']!.isNotEmpty;
|
||||
}
|
||||
|
||||
// ─── Sync Operations ─────────────────────────────────────────
|
||||
|
||||
/// Queue a workout for sync
|
||||
Future<void> queueWorkout(Workout workout) async {
|
||||
final payload = jsonEncode(workout.toApiPayload());
|
||||
await _db.enqueueSync(workout.id, payload);
|
||||
await _updatePendingCount();
|
||||
|
||||
// Try immediate sync if online
|
||||
if (_isOnline.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all pending sync items
|
||||
Future<void> syncPendingWorkouts() async {
|
||||
if (_isSyncing.value) return;
|
||||
_isSyncing.value = true;
|
||||
|
||||
try {
|
||||
final creds = await getCredentials();
|
||||
if (creds['endpoint'] == null || creds['apiKey'] == null ||
|
||||
creds['apiSecret'] == null) {
|
||||
debugPrint('[SYNC] No API credentials configured');
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingItems = await _db.getPendingSyncs();
|
||||
if (pendingItems.isEmpty) return;
|
||||
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
for (final item in pendingItems) {
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
payload: item['payload'] as String,
|
||||
endpoint: creds['endpoint']!,
|
||||
apiKey: creds['apiKey']!,
|
||||
apiSecret: creds['apiSecret']!,
|
||||
);
|
||||
}
|
||||
|
||||
await _db.removeSentFromQueue();
|
||||
await _updatePendingCount();
|
||||
} catch (e) {
|
||||
debugPrint('[SYNC] Error during sync: $e');
|
||||
} finally {
|
||||
_isSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSingleItem({
|
||||
required int id,
|
||||
required String workoutId,
|
||||
required String payload,
|
||||
required String endpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
try {
|
||||
final headers = HmacUtility.buildHeaders(
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$endpoint/api/v1/workouts'),
|
||||
headers: headers,
|
||||
body: payload,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
await _db.updateSyncQueueItem(id, 'sent');
|
||||
await _db.updateSyncStatus(
|
||||
workoutId,
|
||||
SyncStatus.synced,
|
||||
syncedAt: DateTime.now(),
|
||||
);
|
||||
debugPrint('[SYNC] ✅ Workout $workoutId synced');
|
||||
} else {
|
||||
await _db.updateSyncQueueItem(
|
||||
id,
|
||||
'failed',
|
||||
errorMessage: 'HTTP ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
debugPrint('[SYNC] ❌ Failed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _db.updateSyncQueueItem(id, 'failed', errorMessage: e.toString());
|
||||
debugPrint('[SYNC] ❌ Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updatePendingCount() async {
|
||||
final items = await _db.getPendingSyncs();
|
||||
_pendingCount.value = items.length;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_connectivitySub?.cancel();
|
||||
_syncTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user