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:
Hamza-Ayed
2026-08-04 21:42:19 +03:00
co-authored by Claude Opus 5
parent 0de8573d27
commit c52338f3bb
117 changed files with 6061 additions and 374 deletions
@@ -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;
}
}
}