Connect Flutter phone auth and Android platform
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user