Connect Flutter phone auth and Android platform
This commit is contained in:
@@ -7,10 +7,10 @@ import '../utils/geo_utils.dart';
|
||||
/// خدمة التحليل الذكي (Smart Analytics Service)
|
||||
/// مسؤولة عن تقسيم المسار إلى أجزاء، كشف التوقفات، وتحليل أنماط الأداء.
|
||||
class AnalyticsService extends GetxService {
|
||||
|
||||
/// تحليل المسار وتقسيمه إلى أجزاء (Segments)
|
||||
/// يعتمد على تغيرات السرعة، الارتفاع، أو الاتجاه.
|
||||
List<WorkoutSegment> analyzeSegments(List<GpsCoordinate> coords, WorkoutType type) {
|
||||
List<WorkoutSegment> analyzeSegments(
|
||||
List<GpsCoordinate> coords, WorkoutType type) {
|
||||
if (coords.length < 5) return [];
|
||||
|
||||
List<WorkoutSegment> segments = [];
|
||||
@@ -20,35 +20,38 @@ class AnalyticsService extends GetxService {
|
||||
// 1. تحديد مرحلة الإحماء (Warm-up)
|
||||
// نعتبر أول 10% من النقاط أو أول 500 متر إحماء إذا كانت السرعة متدرجة
|
||||
int warmupEnd = (coords.length * 0.1).toInt().clamp(5, 50);
|
||||
segments.add(_createSegment(coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup));
|
||||
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 نقطة لتجنب المقاطع الضخمة
|
||||
while (stopEnd < coords.length - 1 &&
|
||||
(coords[stopEnd].speed ?? 0) < 0.3 &&
|
||||
stopEnd - i < 20) {
|
||||
// توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة
|
||||
stopEnd++;
|
||||
}
|
||||
|
||||
if (stopEnd - i > 3) { // توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
|
||||
if (stopEnd - i > 3) {
|
||||
// توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
// إغلاق المقطع السابق إذا كان هناك مسافة
|
||||
if (i > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(
|
||||
coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
segments.add(_createSegment(coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
segments.add(_createSegment(
|
||||
coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
i = stopEnd;
|
||||
startIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// كشف الميول (Elevation detection)
|
||||
// يمكن التوسع هنا لكشف uphill/downhill
|
||||
}
|
||||
@@ -56,36 +59,37 @@ class AnalyticsService extends GetxService {
|
||||
// 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));
|
||||
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));
|
||||
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) {
|
||||
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
|
||||
);
|
||||
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!);
|
||||
|
||||
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;
|
||||
int duration =
|
||||
coords[end].timestamp.difference(coords[start].timestamp).inSeconds;
|
||||
|
||||
return WorkoutSegment(
|
||||
segmentIndex: index,
|
||||
@@ -104,16 +108,18 @@ class AnalyticsService extends GetxService {
|
||||
/// مثال: "سرعتك في أول 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;
|
||||
|
||||
if (latest.avgSpeedMps > previous.avgSpeedMps && previous.avgSpeedMps > 0) {
|
||||
double diff =
|
||||
((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) *
|
||||
100;
|
||||
return "أداء رائع! سرعتك المتوسطة تحسنت بنسبة ${diff.toStringAsFixed(1)}% مقارنة بآخر مرة.";
|
||||
}
|
||||
|
||||
|
||||
return "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AuthService {
|
||||
AuthService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
static const _apiOrigin = String.fromEnvironment('API_BASE_URL');
|
||||
static const _accessKey = 'sportpath_access_token';
|
||||
static const _refreshKey = 'sportpath_refresh_token';
|
||||
static const _deviceKey = 'sportpath_device_uuid';
|
||||
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
final http.Client _client;
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
String? _deviceUuid;
|
||||
String? _pendingChallengeId;
|
||||
String? _pendingPhone;
|
||||
bool? _pendingRegistration;
|
||||
Future<bool>? _refreshing;
|
||||
bool get isSignedIn => _refreshToken != null && _refreshToken!.isNotEmpty;
|
||||
bool get isApiConfigured => _apiOrigin.startsWith('https://');
|
||||
String get apiBase => '${_apiOrigin.replaceFirst(RegExp(r'/+$'), '')}/api/v1';
|
||||
|
||||
Future<AuthService> init() async {
|
||||
_accessToken = await _storage.read(key: _accessKey);
|
||||
_refreshToken = await _storage.read(key: _refreshKey);
|
||||
_deviceUuid = await _storage.read(key: _deviceKey);
|
||||
_deviceUuid ??= const Uuid().v4();
|
||||
await _storage.write(key: _deviceKey, value: _deviceUuid);
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<void> requestOtp(
|
||||
{required String phoneE164, required bool isRegistration}) async {
|
||||
_requireApi();
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/request-otp.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'phone_e164': phoneE164,
|
||||
'purpose': isRegistration ? 'register' : 'login',
|
||||
'device_uuid': _deviceUuid,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
_throwIfNotSuccessful(response);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_pendingChallengeId = body['challenge_id'] as String?;
|
||||
_pendingPhone = phoneE164;
|
||||
_pendingRegistration = isRegistration;
|
||||
if (_pendingChallengeId == null) {
|
||||
throw const AuthException('لم يصل معرّف التحقق من الخادم.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOtp({
|
||||
required String phoneE164,
|
||||
required String code,
|
||||
required bool isRegistration,
|
||||
String? displayName,
|
||||
}) async {
|
||||
_requireApi();
|
||||
final challengeId = _pendingChallengeId;
|
||||
if (challengeId == null ||
|
||||
_pendingPhone != phoneE164 ||
|
||||
_pendingRegistration != isRegistration) {
|
||||
throw const AuthException('اطلب رمز تحقق جديدًا أولًا.');
|
||||
}
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/verify-otp.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'challenge_id': challengeId,
|
||||
'code': code,
|
||||
'display_name': displayName,
|
||||
'device_uuid': _deviceUuid,
|
||||
'platform':
|
||||
defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android',
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
_throwIfNotSuccessful(response);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = body['access_token'] as String?;
|
||||
_refreshToken = body['refresh_token'] as String?;
|
||||
if (_accessToken == null || _refreshToken == null) {
|
||||
throw const AuthException('ردّ الخادم لا يحتوي بيانات جلسة صالحة.');
|
||||
}
|
||||
await _storage.write(key: _accessKey, value: _accessToken);
|
||||
await _storage.write(key: _refreshKey, value: _refreshToken);
|
||||
_pendingChallengeId = null;
|
||||
_pendingPhone = null;
|
||||
_pendingRegistration = null;
|
||||
}
|
||||
|
||||
Future<http.Response> authenticatedPost(Uri uri, String body) async {
|
||||
_requireApi();
|
||||
_accessToken ??= await _storage.read(key: _accessKey);
|
||||
var token = _accessToken;
|
||||
if (token == null && !await _refreshSession()) {
|
||||
throw const AuthException('سجّل الدخول لمزامنة بياناتك.');
|
||||
}
|
||||
token = _accessToken;
|
||||
var response = await _client
|
||||
.post(uri, headers: _bearerHeaders(token!), body: body)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.post(uri, headers: _bearerHeaders(_accessToken!), body: body)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<bool> _refreshSession() async {
|
||||
final activeRefresh = _refreshing;
|
||||
if (activeRefresh != null) return activeRefresh;
|
||||
final refreshFuture = _performRefresh();
|
||||
_refreshing = refreshFuture;
|
||||
try {
|
||||
return await refreshFuture;
|
||||
} finally {
|
||||
if (identical(_refreshing, refreshFuture)) _refreshing = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh() async {
|
||||
final refreshToken = _refreshToken ?? await _storage.read(key: _refreshKey);
|
||||
if (refreshToken == null || refreshToken.isEmpty || !isApiConfigured) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/refresh.php'),
|
||||
headers: const {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'refresh_token': refreshToken}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
await clearSession();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = body['access_token'] as String?;
|
||||
_refreshToken = body['refresh_token'] as String?;
|
||||
if (_accessToken == null || _refreshToken == null) {
|
||||
await clearSession();
|
||||
return false;
|
||||
}
|
||||
await _storage.write(key: _accessKey, value: _accessToken);
|
||||
await _storage.write(key: _refreshKey, value: _refreshToken);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[AUTH] Refresh failed: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final token = _accessToken ?? await _storage.read(key: _accessKey);
|
||||
if (token != null && isApiConfigured) {
|
||||
try {
|
||||
var response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/logout.php'),
|
||||
headers: _bearerHeaders(token),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 401 && await _refreshSession()) {
|
||||
response = await _client
|
||||
.post(
|
||||
Uri.parse('$apiBase/auth/logout.php'),
|
||||
headers: _bearerHeaders(_accessToken!),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('[AUTH] Remote sign out failed: $error');
|
||||
}
|
||||
}
|
||||
await clearSession();
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
await _storage.delete(key: _accessKey);
|
||||
await _storage.delete(key: _refreshKey);
|
||||
}
|
||||
|
||||
Map<String, String> _bearerHeaders(String token) => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
void _requireApi() {
|
||||
if (!isApiConfigured) {
|
||||
throw const AuthException(
|
||||
'عنوان API غير مضبوط. أضف API_BASE_URL عند بناء التطبيق.');
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfNotSuccessful(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
String message = 'تعذر إكمال الطلب (${response.statusCode}).';
|
||||
try {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
switch (body['error']) {
|
||||
case 'rate_limited':
|
||||
message = 'طلبات كثيرة. انتظر قليلًا ثم حاول مجددًا.';
|
||||
break;
|
||||
case 'otp_provider_not_configured':
|
||||
message = 'خدمة الرسائل لم تُفعّل على الخادم بعد.';
|
||||
break;
|
||||
case 'invalid_code':
|
||||
message = 'رمز التحقق غير صحيح.';
|
||||
break;
|
||||
case 'invalid_or_expired_challenge':
|
||||
message = 'انتهت صلاحية رمز التحقق. اطلب رمزًا جديدًا.';
|
||||
break;
|
||||
case 'verification_failed':
|
||||
message = 'تعذر التحقق من الحساب أو الرمز.';
|
||||
break;
|
||||
case 'account_inactive':
|
||||
message = 'الحساب غير نشط. تواصل مع الدعم.';
|
||||
break;
|
||||
case 'otp_delivery_failed':
|
||||
message = 'تعذر إرسال رمز التحقق. حاول لاحقًا.';
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw AuthException(message);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -245,7 +245,11 @@ class DatabaseService extends GetxService {
|
||||
}
|
||||
|
||||
/// Atomically persist a finished workout, its segments, and its sync outbox item.
|
||||
Future<void> saveCompletedWorkout(Workout workout, String payload) async {
|
||||
Future<void> saveCompletedWorkout(
|
||||
Workout workout,
|
||||
String payload, {
|
||||
bool queueForSync = true,
|
||||
}) async {
|
||||
await _db.transaction((txn) async {
|
||||
final saved = await txn.query(
|
||||
'workouts',
|
||||
@@ -286,6 +290,8 @@ class DatabaseService extends GetxService {
|
||||
await txn.insert('workout_segments', values);
|
||||
}
|
||||
|
||||
if (!queueForSync) return;
|
||||
|
||||
final queued = await txn.query(
|
||||
'sync_queue',
|
||||
columns: ['id', 'status'],
|
||||
@@ -438,10 +444,13 @@ class DatabaseService extends GetxService {
|
||||
}
|
||||
|
||||
/// Get all queued items
|
||||
Future<List<Map<String, dynamic>>> getPendingSyncs() async {
|
||||
Future<List<Map<String, dynamic>>> getPendingSyncs(
|
||||
{bool includeExhausted = false}) async {
|
||||
return await _db.query(
|
||||
'sync_queue',
|
||||
where: "status IN ('queued', 'failed') AND retry_count < 5",
|
||||
where: includeExhausted
|
||||
? "status IN ('queued', 'failed')"
|
||||
: "status IN ('queued', 'failed') AND retry_count < 5",
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,15 @@ 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 'auth_service.dart';
|
||||
import 'database_service.dart';
|
||||
|
||||
/// محرك المزامنة التلقائي
|
||||
/// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة
|
||||
class SyncService extends GetxService {
|
||||
final DatabaseService _db = Get.find<DatabaseService>();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final AuthService _auth = Get.find<AuthService>();
|
||||
final _connectivity = Connectivity();
|
||||
|
||||
StreamSubscription? _connectivitySub;
|
||||
@@ -27,10 +25,6 @@ class SyncService extends GetxService {
|
||||
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';
|
||||
|
||||
Future<SyncService> init() async {
|
||||
await _checkConnectivity();
|
||||
_startConnectivityMonitor();
|
||||
@@ -73,44 +67,20 @@ class SyncService extends GetxService {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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.saveCompletedWorkout(workout, payload);
|
||||
final queueForSync = workout.coordinates.length >= 2 &&
|
||||
workout.routePolyline != null &&
|
||||
workout.routePolyline!.isNotEmpty;
|
||||
await _db.saveCompletedWorkout(workout, payload,
|
||||
queueForSync: queueForSync);
|
||||
await _updatePendingCount();
|
||||
|
||||
// Try immediate sync if online
|
||||
if (_isOnline.value) {
|
||||
if (queueForSync && _isOnline.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
}
|
||||
@@ -124,32 +94,29 @@ class SyncService extends GetxService {
|
||||
}
|
||||
|
||||
/// Process all pending sync items
|
||||
Future<void> syncPendingWorkouts() async {
|
||||
Future<void> syncPendingWorkouts({bool retryNow = false}) 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');
|
||||
if (!_auth.isSignedIn) {
|
||||
debugPrint('[SYNC] Sign in to sync saved workouts');
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingItems = await _db.getPendingSyncs();
|
||||
final pendingItems = await _db.getPendingSyncs(
|
||||
includeExhausted: retryNow,
|
||||
);
|
||||
if (pendingItems.isEmpty) return;
|
||||
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
for (final item in pendingItems) {
|
||||
if (!_isRetryDue(item)) continue;
|
||||
if (!retryNow && !_isRetryDue(item)) continue;
|
||||
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']!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,25 +133,13 @@ class SyncService extends GetxService {
|
||||
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 _auth.authenticatedPost(
|
||||
Uri.parse('${_auth.apiBase}/workouts.php'),
|
||||
payload,
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$endpoint/api/v1/workouts'),
|
||||
headers: headers,
|
||||
body: payload,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
await _db.updateSyncQueueItem(id, 'sent');
|
||||
await _db.updateSyncStatus(
|
||||
|
||||
Reference in New Issue
Block a user