feat(apps): نظام التصميم والمصادقة الكاملة — المرحلتان 2 و3
## تصحيح بنيوي أولاً
بنية المرحلة 1 كانت تخالف docs/23 §1 المُلزِم. أُعيدت للشجرة المفروضة
حرفياً: app.dart في الجذر · core/{config,build_config,di,router}.dart ·
core/{design,ui,l10n,api,storage,session}/ · features/<f>/{cubit,data,view}/
## المرحلة 2 — نظام التصميم (docs/26)
- core/design: tokens (مسافات/زوايا/حركة/أحجام — الشاشة لا تخترع رقماً) ·
typography (IBM Plex Sans Arabic + Inter محليّان، بأرقام tabular للأسعار
والعدّادات) · TripzColors كـThemeExtension للأدوار الدلالية ·
buildTheme(brightness, locale) بمدخلين لا ثالث لهما
- core/ui: 11 مكوّناً. TripzScaffold يحوّل status الـCubit وحده إلى
skeleton/خطأ/فراغ/محتوى — فلا تكتب أي شاشة if (loading)
- core/l10n: ARB عربي/إنجليزي + gen_l10n. لا نص مرئي داخل widget
- SettingsCubit: المظهر واللغة. الاتجاه يتبع اللغة آلياً بلا Directionality
مفروضة، وسقف تكبير النص 1.3
## المرحلة 3 — المصادقة
- طبقة الشبكة: تجديد استباقي بطلقة واحدة (عمر التوكن 15 دقيقة) ·
x-app-role · x-device-id من device_info_plus — يُرسَل الآن كي يُفعَّل علم
الخادم لاحقاً بلا تعديل التطبيق
- SessionCubit في core/session خلف واجهة SessionSource: الجلسة حالة على
مستوى التطبيق لا ميزة، و core لا يستورد من features
- LoginCubit بخطواته الخمس، ProfileCubit، وست شاشات:
الشروط ← إذن الموقع ← الهاتف ← الرمز ← إكمال الملف ← هيكل الرئيسية
من سيرو نُقل السلوك لا الكود: بوابتان قبل أي حقل إدخال · إعادة فحص الإذن عند
العودة من إعدادات النظام · تحقّق الهاتف الثلاثي. وأُضيف ما ينقصه: عدّاد إعادة
إرسال الرمز — بدونه تُحظر ثلاث ضغطات المستخدمَ خمس دقائق (docs/38 §2).
قراران موثّقان في docs/37: لا packages/tripz_ui (الوثيقتان المُلزِمتان تفرضان
core/design و core/ui داخل التطبيق)، والـCubit يُصدر رمز فشل لا نصّاً
(يجمع بين docs/23 §3 و docs/26 §4).
flutter analyze نظيف في التطبيقين · 48 ملف و3,140 سطر لكل تطبيق · الفرق
بينهما أربعة ملفات فقط: build_config · config · ملفّا ARB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0de8573d27
commit
c52338f3bb
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
|
||||
/// الواجهة الوحيدة للخادم. لا تُنشأ `Dio` في أي مكان آخر.
|
||||
class ApiClient {
|
||||
ApiClient._(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
factory ApiClient.create({
|
||||
required TokenStore tokens,
|
||||
required Future<void> Function() onSessionExpired,
|
||||
}) {
|
||||
final options = BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.receiveTimeout,
|
||||
contentType: Headers.jsonContentType,
|
||||
// نتولّى كل ما ليس 2xx بأنفسنا في ApiException.
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
);
|
||||
|
||||
final dio = Dio(options);
|
||||
dio.interceptors.add(AuthInterceptor(
|
||||
tokens: tokens,
|
||||
refreshClient: Dio(options),
|
||||
onSessionExpired: onSessionExpired,
|
||||
));
|
||||
return ApiClient._(dio);
|
||||
}
|
||||
|
||||
Future<T> get<T>(String path, {Map<String, dynamic>? query}) =>
|
||||
_send(() => _dio.get<T>(path, queryParameters: query));
|
||||
|
||||
Future<T> post<T>(String path, {Object? body}) =>
|
||||
_send(() => _dio.post<T>(path, data: body));
|
||||
|
||||
Future<T> patch<T>(String path, {Object? body}) =>
|
||||
_send(() => _dio.patch<T>(path, data: body));
|
||||
|
||||
Future<T> delete<T>(String path) => _send(() => _dio.delete<T>(path));
|
||||
|
||||
Future<T> _send<T>(Future<Response<T>> Function() call) async {
|
||||
try {
|
||||
final res = await call();
|
||||
return res.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.from(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// خطأ موحّد تراه طبقة الميزات — لا `DioException` يتسرّب فوق طبقة الشبكة.
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.kind = ApiErrorKind.unknown,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final ApiErrorKind kind;
|
||||
|
||||
bool get isUnauthorized => statusCode == 401;
|
||||
bool get isForbidden => statusCode == 403;
|
||||
|
||||
/// حدّ المعدّل — `send-otp` ثلاث مرات كل خمس دقائق (docs/38 §2). الواجهة
|
||||
/// تعرض عدّاداً تنازلياً بدل رسالة خطأ عامة.
|
||||
bool get isRateLimited => statusCode == 429;
|
||||
|
||||
factory ApiException.from(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const ApiException(
|
||||
message: 'انتهت مهلة الاتصال',
|
||||
kind: ApiErrorKind.timeout,
|
||||
);
|
||||
case DioExceptionType.connectionError:
|
||||
return const ApiException(
|
||||
message: 'تعذّر الاتصال بالخادم',
|
||||
kind: ApiErrorKind.network,
|
||||
);
|
||||
case DioExceptionType.cancel:
|
||||
return const ApiException(
|
||||
message: 'أُلغي الطلب',
|
||||
kind: ApiErrorKind.cancelled,
|
||||
);
|
||||
default:
|
||||
final code = e.response?.statusCode;
|
||||
return ApiException(
|
||||
message: _messageOf(e.response?.data) ?? 'حدث خطأ غير متوقّع',
|
||||
statusCode: code,
|
||||
kind: ApiErrorKind.server,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// NestJS يرجّع `message` نصاً أو مصفوفة نصوص (أخطاء التحقق).
|
||||
static String? _messageOf(dynamic data) {
|
||||
if (data is Map) {
|
||||
final m = data['message'];
|
||||
if (m is String && m.isNotEmpty) return m;
|
||||
if (m is List && m.isNotEmpty) return m.join('\n');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($statusCode): $message';
|
||||
}
|
||||
|
||||
enum ApiErrorKind { network, timeout, server, cancelled, unknown }
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../build_config.dart';
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
|
||||
///
|
||||
/// عمر `access_token` خمس عشرة دقيقة فقط (docs/38 §2). لذلك لا ننتظر 401:
|
||||
/// نجدّد قبل الإرسال إن قاربت الصلاحية. الـ401 يبقى شبكة أمان لا الآلية.
|
||||
///
|
||||
/// التجديد **بطلقة واحدة**: عشرة طلبات متزامنة بتوكن منتهٍ تُنتج نداء تجديد
|
||||
/// واحداً لا عشرة — وإلا أبطل الخادم رمز التحديث تحت أقدامنا.
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor({
|
||||
required TokenStore tokens,
|
||||
required Dio refreshClient,
|
||||
required Future<void> Function() onSessionExpired,
|
||||
}) : _tokens = tokens,
|
||||
_refreshClient = refreshClient,
|
||||
_onSessionExpired = onSessionExpired;
|
||||
|
||||
final TokenStore _tokens;
|
||||
|
||||
/// عميل منفصل بلا هذا الـinterceptor — وإلا استدعى التجديدُ نفسَه.
|
||||
final Dio _refreshClient;
|
||||
|
||||
final Future<void> Function() _onSessionExpired;
|
||||
|
||||
Future<bool>? _inFlight;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
options.headers['x-tenant-id'] = BuildConfig.tenantSlug;
|
||||
options.headers['x-app-role'] = AppConfig.appRole;
|
||||
|
||||
final deviceId = await _tokens.deviceId();
|
||||
if (deviceId != null) options.headers['x-device-id'] = deviceId;
|
||||
|
||||
// نقاط المصادقة لا تحمل توكناً ولا تُشغّل تجديداً.
|
||||
if (!_needsAuth(options.path)) return handler.next(options);
|
||||
|
||||
if (_tokens.isLoggedIn &&
|
||||
_tokens.isAccessExpired(AppConfig.tokenRefreshLeeway)) {
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
final access = _tokens.accessToken;
|
||||
if (access != null && access.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $access';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final options = err.requestOptions;
|
||||
final retried = options.extra['__retried'] == true;
|
||||
|
||||
if (err.response?.statusCode != 401 ||
|
||||
retried ||
|
||||
!_needsAuth(options.path) ||
|
||||
!_tokens.isLoggedIn) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
if (!await _refresh()) return handler.next(err);
|
||||
|
||||
options.extra['__retried'] = true;
|
||||
options.headers['Authorization'] = 'Bearer ${_tokens.accessToken}';
|
||||
try {
|
||||
handler.resolve(await _refreshClient.fetch(options));
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
}
|
||||
}
|
||||
|
||||
static bool _needsAuth(String path) =>
|
||||
!path.startsWith('/auth/') &&
|
||||
!path.startsWith('/maps/') &&
|
||||
!path.startsWith('/tenant/config');
|
||||
|
||||
Future<bool> _refresh() {
|
||||
return _inFlight ??= _doRefresh().whenComplete(() => _inFlight = null);
|
||||
}
|
||||
|
||||
Future<bool> _doRefresh() async {
|
||||
final refresh = _tokens.refreshToken;
|
||||
if (refresh == null || refresh.isEmpty) return false;
|
||||
try {
|
||||
final deviceId = await _tokens.deviceId();
|
||||
final res = await _refreshClient.post<Map<String, dynamic>>(
|
||||
'/auth/refresh',
|
||||
data: {'refresh_token': refresh},
|
||||
options: Options(headers: {
|
||||
'x-tenant-id': BuildConfig.tenantSlug,
|
||||
'x-app-role': AppConfig.appRole,
|
||||
'x-device-id': ?deviceId,
|
||||
}),
|
||||
);
|
||||
final body = res.data;
|
||||
final access = body?['access_token'] as String?;
|
||||
if (access == null || access.isEmpty) return false;
|
||||
await _tokens.save(
|
||||
accessToken: access,
|
||||
// الخادم قد لا يدوّر رمز التحديث — نحتفظ بالقديم حينها.
|
||||
refreshToken: (body?['refresh_token'] as String?) ?? refresh,
|
||||
signingKey: body?['signing_key'] as String?,
|
||||
);
|
||||
return true;
|
||||
} on DioException {
|
||||
// رمز تحديث ميّت = جلسة منتهية. لا معنى للمحاولة مجدداً.
|
||||
await _tokens.clear();
|
||||
await _onSessionExpired();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user