chore: أرشفة تطبيقات ولوحات تريبز التي سيحلّ محلها كود سيرو
استكمال قرار 2026-07-27 (كوميت fff76c9 أرشف الباك إند):
apps/rider → apps/rider-archive-cubit
apps/driver → apps/driver-archive-cubit
dashboards/admin-web → dashboards/admin-web-archive
dashboards/service-web → dashboards/service-web-archive
إعادة تسمية بلا تعديل محتوى. rider/driver كانا إعادة بناء Cubit مبنية على
عقد NestJS المؤرشف. admin-web و service-web نموذجان بملف HTML واحد (164 و
128 سطراً) — يحلّ محلهما siro_admin و siro_service المكتملان.
يُحمل من المؤرشف ولا يُعاد اكتشافه: عقد map-saas المثبَّت بالتجربة في
apps/rider-archive-cubit/lib/core/api/ (x-api-key ترويسة، reverse مصفوفة،
route في points) ودرس مسار FCM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fff76c949f
commit
9909d9b4c1
@@ -0,0 +1,65 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config.dart';
|
||||
|
||||
/// واجهة مباشرة مع انطلق (map-saas) — لا مرور عبر الباك إند.
|
||||
///
|
||||
/// كل نداء يمر عبر هذه النافذة: البحث، التفكيك العكسي، المسار.
|
||||
/// الباك إند يبقى مسؤولاً عن المصادقة والرحلات والمدفوعات فقط.
|
||||
class AntlaqApi {
|
||||
final String apiKey;
|
||||
final Dio _dio;
|
||||
|
||||
AntlaqApi({String? apiKey})
|
||||
: apiKey = apiKey ?? AppConfig.mapApiKey,
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: 'https://map-saas.intaleqapp.com',
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
// المفتاح يمر كترويسة `x-api-key` — map-saas يرفض أي مفتاح في
|
||||
// query (تحقّق صارم: `property api_key should not exist`).
|
||||
// المفتاح مقيّد ببصمة التطبيق (bundle id) على map-saas نفسه.
|
||||
headers: {'x-api-key': apiKey ?? AppConfig.mapApiKey},
|
||||
));
|
||||
|
||||
/// بحث أماكن — GET /api/geocoding/search.
|
||||
Future<List<dynamic>> search(String query) async {
|
||||
final r = await _dio.get('/api/geocoding/search', queryParameters: {
|
||||
'q': query,
|
||||
});
|
||||
return (r.data['results'] ?? r.data) as List;
|
||||
}
|
||||
|
||||
/// تفكيك عنوان عكسي — GET /api/geocoding/reverse.
|
||||
/// يرجّع map-saas **مصفوفة** مرتّبة بالأقرب؛ نأخذ العنصر الأول.
|
||||
Future<Map<String, dynamic>> reverse(double lat, double lng) async {
|
||||
final r = await _dio.get('/api/geocoding/reverse', queryParameters: {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
});
|
||||
final data = r.data;
|
||||
if (data is List) {
|
||||
if (data.isEmpty) return {'latitude': lat, 'longitude': lng};
|
||||
return Map<String, dynamic>.from(data.first as Map);
|
||||
}
|
||||
return Map<String, dynamic>.from(data as Map);
|
||||
}
|
||||
|
||||
/// حساب مسار بين نقطتين — GET /api/maps/route.
|
||||
Future<Map<String, dynamic>> route({
|
||||
required double fromLat,
|
||||
required double fromLng,
|
||||
required double toLat,
|
||||
required double toLng,
|
||||
}) async {
|
||||
final r = await _dio.get('/api/maps/route', queryParameters: {
|
||||
'fromLat': fromLat,
|
||||
'fromLng': fromLng,
|
||||
'toLat': toLat,
|
||||
'toLng': toLng,
|
||||
'alternatives': true,
|
||||
'steps': true,
|
||||
'locale': 'ar',
|
||||
});
|
||||
return r.data as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
import 'logging_interceptor.dart';
|
||||
|
||||
/// عميل HTTP لكل نداءات الـAPI (docs/23 §4).
|
||||
///
|
||||
/// **نقطة الشبكة الوحيدة** في التطبيق: لا `http.get` ولا `Dio` آخر في أي
|
||||
/// مكان. السبب أن كل ما يفرضه السيرفر (المستأجر · التوكن · بصمة الجهاز ·
|
||||
/// التوقيع) يُحقن هنا مرة واحدة؛ نداءٌ يلتفّ حول هذا الملف هو نداء بلا
|
||||
/// مصادقة يفشل عند تفعيل الحراسات على السيرفر.
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
final TokenStore _store;
|
||||
|
||||
/// بصمة الجهاز (docs/17 — D2). تُحسب مرة وتُخزَّن: قراءتها من النظام في كل
|
||||
/// طلب استدعاءُ قناة أصيلة بلا داعٍ.
|
||||
static String? _deviceId;
|
||||
|
||||
ApiClient(this._store)
|
||||
: dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
// Resolve device ID once in background — don't block requests
|
||||
_resolveDeviceId();
|
||||
|
||||
dio.interceptors.add(AppLoggingInterceptor());
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
|
||||
// راكب أم سائق — هويتان منفصلتان لنفس الرقم (قرار المالك 2026-07-20 — ب).
|
||||
options.headers['x-app-role'] = AppConfig.appRole;
|
||||
|
||||
// Device ID is cached after first call — non-blocking
|
||||
final device = _deviceId;
|
||||
if (device != null) options.headers['x-device-id'] = device;
|
||||
|
||||
final token = await _store.access();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) async {
|
||||
// تجديد التوكن عند 401 — **مرة واحدة**. بلا علامة `_retried` يعيد
|
||||
// الطلبُ الفاشل تجديداً يفشل فيعيد… حلقة لانهائية تستنزف البطارية
|
||||
// وتغرق السيرفر عند انتهاء الجلسة فعلاً.
|
||||
final isAuthError = e.response?.statusCode == 401;
|
||||
final alreadyRetried = e.requestOptions.extra['_retried'] == true;
|
||||
final isRefreshCall = e.requestOptions.path.contains('/auth/refresh');
|
||||
|
||||
if (!isAuthError || alreadyRetried || isRefreshCall) {
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
final refreshed = await _tryRefresh();
|
||||
if (!refreshed) {
|
||||
await _store.clear(); // الجلسة انتهت فعلاً — لا نُبقي توكناً ميتاً
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
try {
|
||||
e.requestOptions.extra['_retried'] = true;
|
||||
final retry = await dio.fetch(e.requestOptions);
|
||||
return handler.resolve(retry);
|
||||
} on DioException catch (err) {
|
||||
return handler.next(err);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<bool> _tryRefresh() async {
|
||||
final rt = await _store.refresh();
|
||||
if (rt == null || rt.isEmpty) return false;
|
||||
try {
|
||||
// Dio نظيف: المرور بالعميل نفسه يُدخل التوكن المنتهي في الترويسة.
|
||||
final plain = Dio(BaseOptions(baseUrl: AppConfig.baseUrl));
|
||||
final r = await plain.post('/auth/refresh', data: {'refresh_token': rt});
|
||||
await _store.save(
|
||||
r.data['access_token'] as String,
|
||||
(r.data['refresh_token'] ?? rt) as String,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _resolveDeviceId() async {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
_deviceId = (await info.androidInfo).id;
|
||||
} else if (Platform.isIOS) {
|
||||
_deviceId = (await info.iosInfo).identifierForVendor;
|
||||
}
|
||||
} catch (_) {
|
||||
// تعذّر قراءة الجهاز: نُرسل بلا الترويسة بدل إسقاط الطلب. السيرفر
|
||||
// يرفض عند تفعيل الربط، وهذا أوضح من فشل صامت هنا.
|
||||
_deviceId = null;
|
||||
}
|
||||
return _deviceId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// استثناءات الشبكة الموحّدة — كل خطأ يتحول إلى صنف هنا.
|
||||
/// docs/23 §4
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.message, {this.statusCode, this.type});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final ApiExceptionType? type;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($statusCode): $message';
|
||||
}
|
||||
|
||||
enum ApiExceptionType {
|
||||
network,
|
||||
timeout,
|
||||
unauthorized,
|
||||
forbidden,
|
||||
notFound,
|
||||
server,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// تحليل DioException إلى ApiException
|
||||
ApiException fromDioError(Object error) {
|
||||
final dio = error;
|
||||
final type = switch (dio.runtimeType.toString()) {
|
||||
'DioException' => _mapDioType(dio),
|
||||
_ => ApiExceptionType.unknown,
|
||||
};
|
||||
|
||||
final statusCode = _extractStatusCode(dio);
|
||||
final message = _extractMessage(dio, statusCode);
|
||||
|
||||
return ApiException(message, statusCode: statusCode, type: type);
|
||||
}
|
||||
|
||||
int? _extractStatusCode(Object error) {
|
||||
try {
|
||||
return (error as dynamic).response?.statusCode as int?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _extractMessage(Object error, int? statusCode) {
|
||||
try {
|
||||
final data = (error as dynamic).response?.data;
|
||||
if (data is Map) {
|
||||
return (data['message'] ?? data['error'] ?? 'خطأ غير معروف').toString();
|
||||
}
|
||||
} catch (_) {}
|
||||
return switch (statusCode) {
|
||||
401 => 'انتهت الجلسة',
|
||||
403 => 'غير مصرح',
|
||||
404 => 'غير موجود',
|
||||
500 => 'خطأ في السيرفر',
|
||||
_ => 'خطأ غير معروف',
|
||||
};
|
||||
}
|
||||
|
||||
ApiExceptionType _mapDioType(Object error) {
|
||||
try {
|
||||
final dioType = (error as dynamic).type;
|
||||
return switch (dioType.toString()) {
|
||||
'DioExceptionType.connectionTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.sendTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.receiveTimeout' => ApiExceptionType.timeout,
|
||||
'DioExceptionType.connectionError' => ApiExceptionType.network,
|
||||
'DioExceptionType.badResponse' => ApiExceptionType.server,
|
||||
'DioExceptionType.cancel' => ApiExceptionType.unknown,
|
||||
_ => ApiExceptionType.unknown,
|
||||
};
|
||||
} catch (_) {
|
||||
return ApiExceptionType.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// مساطر طباعة كل طلب HTTP — يظهر فقط في وضع التطوير.
|
||||
class AppLoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final method = options.method;
|
||||
final uri = options.uri;
|
||||
final payload = options.data;
|
||||
|
||||
debugPrint('┌─────── REQUEST ───────');
|
||||
debugPrint('│ $method $uri');
|
||||
if (payload != null) {
|
||||
debugPrint('└────── Payload ───────');
|
||||
try {
|
||||
final json = jsonDecode(payload.toString());
|
||||
debugPrint('${const JsonEncoder.withIndent(' ').convert(json)}');
|
||||
} catch (_) {
|
||||
debugPrint('$payload');
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final status = response.statusCode;
|
||||
final uri = response.requestOptions.uri;
|
||||
final body = response.data;
|
||||
|
||||
debugPrint('┌─────── RESPONSE [$status] ───────');
|
||||
debugPrint('│ $uri');
|
||||
if (body != null) {
|
||||
try {
|
||||
final json = jsonDecode(body.toString());
|
||||
debugPrint('${const JsonEncoder.withIndent(' ').convert(json)}');
|
||||
} catch (_) {
|
||||
debugPrint('$body');
|
||||
}
|
||||
}
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
final status = err.response?.statusCode;
|
||||
final uri = err.requestOptions.uri;
|
||||
final message = err.message;
|
||||
|
||||
debugPrint('┌─────── ERROR [$status] ───────');
|
||||
debugPrint('│ $uri');
|
||||
debugPrint('│ $message');
|
||||
if (err.response?.data != null) {
|
||||
debugPrint('│ ${err.response?.data}');
|
||||
}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user