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:
Hamza-Ayed
2026-08-04 21:21:26 +03:00
co-authored by Claude Opus 5
parent 1243e5a3b2
commit 69da4abc01
379 changed files with 15686 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/theme/app_theme.dart';
import 'router.dart';
class TripzApp extends StatelessWidget {
const TripzApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Tripz',
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
// اللغات الأربع تُستكمل في م2 مع ملفات الترجمة.
locale: const Locale('ar'),
supportedLocales: const [Locale('ar'), Locale('en')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:go_router/go_router.dart';
import '../features/splash/splash_page.dart';
/// جدول التوجيه — يمتدّ في م3 (المصادقة) وم4 (الخريطة).
class AppRoutes {
const AppRoutes._();
static const splash = '/';
}
final appRouter = GoRouter(
initialLocation: AppRoutes.splash,
routes: [
GoRoute(
path: AppRoutes.splash,
builder: (context, state) => const SplashPage(),
),
],
);
@@ -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 = 'driver';
/// هامش التجديد الاستباقي. عمر التوكن 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);
}
+31
View File
@@ -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,
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
/// شاشة الإقلاع. في م3 تتولّى استعادة الجلسة والتوجيه؛ الآن تثبت فقط أن
/// السقالة والأصول والخطوط تعمل (بوابة م1).
class SplashPage extends StatelessWidget {
const SplashPage({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/logo.png',
width: 140,
height: 140,
fit: BoxFit.contain,
),
const SizedBox(height: 24),
Text(
'Tripz',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
const SizedBox(height: 8),
Text(
AppConfig.appRole == 'driver' ? 'تطبيق السائق' : 'تطبيق الراكب',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
],
),
),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'app/app.dart';
import 'core/di/injector.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await setupInjector();
runApp(const TripzApp());
}