feat(apps): سقالة rider_new و driver_new على Cubit — المرحلة 1
بناء من الصفر على باك إند NestJS، بقرار المالك 2026-08-04 الذي يعكس قرارَي نقل GetX (2026-07-21) واعتماد باك إند PHP (2026-07-27). لا يُورَّث سطر دارت من apps/*-archive-cubit، وسيرو مرجع بصري وسلوكي لا مصدر نسخ. الوثائق: - docs/37: الخطة الكاملة بستّ مراحل وبواباتها - docs/38: عقد الـAPI من 37 controller، معظمه متحقَّق حيّاً من السيرفر المنشور - docs/39: جرد 202 شاشة في تطبيقَي سيرو، مصنّفة داخل/خارج النطاق الطبقة الأصلية منقولة من *-archive-cubit وحدها لأنها هوية النشر: - rider_new → com.mobileapp.store.ride · shorebird 496cb3ac - driver_new → com.sefer_driver (أندرويد) · com.sefer.driver (iOS) · 68cc9345 - أُصلح تعارض: هدف RunnerTests في driver_new كان يحمل bundle الراكب الأصول مصدرها *-archive-cubit لا سيرو: أصول الأرشيف مجموعة أشمل (كل صور سيرو + صور تريبز) وخطوطها هي خطوط docs/26. استُكمل السائق بعشرة ملفات ناقصة من سيرو (شعارات مزوّدي الدفع + صوتان). lib/ مكتوب من الصفر (11 ملف لكل تطبيق): - AppConfig بأعلام const — أساس نموذج lite/pro/max - TokenStore على التخزين الآمن، يقرأ exp محليّاً بلا حزمة خارجية - AuthInterceptor بتجديد استباقي وطلقة واحدة — عمر التوكن 15 دقيقة فقط - ApiClient و ApiException يفهم مصفوفة message في NestJS flutter analyze نظيف في التطبيقين. البناء الفعلي لم يُجرَّب بعد: بوابة المرحلة 1 تتطلّب البناء على السيرفر، والوصول إليه غير متاح حالياً. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1243e5a3b2
commit
69da4abc01
@@ -0,0 +1,36 @@
|
||||
/// إعداد البناء — كل قيمة هنا `const` تُمرَّر عبر `--dart-define` عند البناء.
|
||||
///
|
||||
/// هذا هو أساس نموذج lite/pro/max (docs/37 §6.25): بناء مولَّد بأعلام ثابتة،
|
||||
/// لا فروع كود. القيم الافتراضية تشير للسيرفر التجريبي.
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// أصل الـAPI **مع** `/api` — كما في docs/38.
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
||||
);
|
||||
|
||||
/// أصل الـWebSocket — بلا `/api` (docs/38 §9).
|
||||
static const String wsBaseUrl = String.fromEnvironment(
|
||||
'WS_BASE_URL',
|
||||
defaultValue: 'https://tripz-api.intaleqapp.com',
|
||||
);
|
||||
|
||||
/// **slug** المستأجر لا الـUUID — مصيدة docs/38 §12.9.
|
||||
static const String tenantSlug = String.fromEnvironment(
|
||||
'TENANT_SLUG',
|
||||
defaultValue: 'siro',
|
||||
);
|
||||
|
||||
/// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان
|
||||
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
||||
static const String appRole = 'rider';
|
||||
|
||||
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار
|
||||
/// حتى 401 يعني فشل طلب في منتصف رحلة.
|
||||
static const Duration tokenRefreshLeeway = Duration(seconds: 60);
|
||||
|
||||
static const Duration connectTimeout = Duration(seconds: 20);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// يُنادى مرة واحدة قبل `runApp`.
|
||||
Future<void> setupInjector() async {
|
||||
sl.registerSingleton<SharedPreferences>(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
const secure = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final tokens = TokenStore(secure);
|
||||
await tokens.load();
|
||||
sl.registerSingleton<TokenStore>(tokens);
|
||||
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient.create(
|
||||
tokens: tokens,
|
||||
// يُربط بالتوجيه في م3 (إخراج للمستخدم عند انتهاء الجلسة).
|
||||
onSessionExpired: () async {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_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,125 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_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'] = AppConfig.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': AppConfig.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// مخزن الرموز — التخزين الآمن وحده. لا يُكتب توكن في `SharedPreferences`.
|
||||
class TokenStore {
|
||||
TokenStore(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const _kAccess = 'access_token';
|
||||
static const _kRefresh = 'refresh_token';
|
||||
static const _kSigning = 'signing_key';
|
||||
static const _kDeviceId = 'device_id';
|
||||
|
||||
// نسخة في الذاكرة: الـinterceptor يقرأ التوكن عند كل طلب، وقراءة Keychain
|
||||
// في كل مرة عمل ثقيل على المسار الساخن.
|
||||
String? _access;
|
||||
String? _refresh;
|
||||
|
||||
Future<void> load() async {
|
||||
_access = await _storage.read(key: _kAccess);
|
||||
_refresh = await _storage.read(key: _kRefresh);
|
||||
}
|
||||
|
||||
String? get accessToken => _access;
|
||||
String? get refreshToken => _refresh;
|
||||
bool get isLoggedIn => _refresh != null && _refresh!.isNotEmpty;
|
||||
|
||||
Future<void> save({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
String? signingKey,
|
||||
}) async {
|
||||
_access = accessToken;
|
||||
_refresh = refreshToken;
|
||||
await _storage.write(key: _kAccess, value: accessToken);
|
||||
await _storage.write(key: _kRefresh, value: refreshToken);
|
||||
if (signingKey != null) {
|
||||
await _storage.write(key: _kSigning, value: signingKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_access = null;
|
||||
_refresh = null;
|
||||
await _storage.delete(key: _kAccess);
|
||||
await _storage.delete(key: _kRefresh);
|
||||
await _storage.delete(key: _kSigning);
|
||||
// مُعرّف الجهاز لا يُمسح عند الخروج — هو بصمة الجهاز لا بصمة الجلسة.
|
||||
}
|
||||
|
||||
/// مفتاح توقيع الطلبات — يعود مع رد الدخول (docs/38 §8). يُستهلك في م3.
|
||||
Future<String?> signingKey() => _storage.read(key: _kSigning);
|
||||
|
||||
Future<String?> deviceId() => _storage.read(key: _kDeviceId);
|
||||
Future<void> setDeviceId(String id) =>
|
||||
_storage.write(key: _kDeviceId, value: id);
|
||||
|
||||
/// هل انتهت صلاحية التوكن (أو ستنتهي خلال [leeway])؟
|
||||
///
|
||||
/// قراءة محليّة لحقل `exp` — بلا حزمة خارجية. أي عطب في التوكن يُعامل
|
||||
/// كمنتهٍ: الأسوأ تجديد زائد، لا طلب فاشل.
|
||||
bool isAccessExpired(Duration leeway) {
|
||||
final token = _access;
|
||||
if (token == null || token.isEmpty) return true;
|
||||
final exp = _expiryOf(token);
|
||||
if (exp == null) return true;
|
||||
return DateTime.now().add(leeway).isAfter(exp);
|
||||
}
|
||||
|
||||
static DateTime? _expiryOf(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) return null;
|
||||
final payload = json.decode(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))),
|
||||
) as Map<String, dynamic>;
|
||||
final exp = payload['exp'];
|
||||
if (exp is! int) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// سمة مبدئية — تُستبدل بنظام التصميم الكامل في م2 (docs/26).
|
||||
/// الغرض الآن: تثبيت الخطوط وأن يقلع التطبيق بشكل صحيح، لا أكثر.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static const seed = Color(0xFF1F6FEB);
|
||||
static const arabicFont = 'IBMPlexSansArabic';
|
||||
|
||||
static ThemeData light() => _base(Brightness.light);
|
||||
static ThemeData dark() => _base(Brightness.dark);
|
||||
|
||||
static ThemeData _base(Brightness brightness) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: arabicFont,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user