first commit

This commit is contained in:
Hamza-Ayed
2026-10-04 00:19:45 +03:00
commit 05f1c9ec6b
93 changed files with 10782 additions and 0 deletions
+119
View File
@@ -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 "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
}
}
+482
View File
@@ -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();
}
}
+130
View File
@@ -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();
}
}
+213
View File
@@ -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();
}
}