Files
fitness/mobile/lib/services/auth_service.dart
T

252 lines
8.9 KiB
Dart

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;
}