Connect Flutter phone auth and Android platform

This commit is contained in:
Hamza-Ayed
2026-10-04 01:44:07 +03:00
parent 5b90da6b18
commit 98490a17e6
44 changed files with 1271 additions and 168 deletions
+38 -32
View File
@@ -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 "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
}
}
+251
View File
@@ -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;
}
+12 -3
View File
@@ -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',
);
}
+18 -63
View File
@@ -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(