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,50 @@
import 'package:equatable/equatable.dart';
/// المستخدم كما يرجّعه الخادم (docs/38 §2 — شكل مُتحقَّق حيّاً).
class AppUser extends Equatable {
const AppUser({
required this.id,
required this.tenantId,
required this.phone,
required this.role,
required this.status,
required this.language,
this.name,
this.rating,
});
final String id;
final String tenantId;
/// مطبَّع دولياً من الخادم (`0790000001` ← `962790000001`) — لا يُقارن نصّياً
/// بما أدخله المستخدم (docs/38 §2).
final String phone;
final String role;
final String status;
final String language;
final String? name;
/// نصّ لا رقم — كل الأرقام العشرية تصل نصوصاً (docs/38 §12.2).
final String? rating;
/// `phone_bidx` يصل في الرد لكنه فهرس أعمى للخادم — **لا يُخزَّن ولا يُعرض**.
factory AppUser.fromJson(Map<String, dynamic> json) {
return AppUser(
id: json['id'] as String,
tenantId: json['tenant_id'] as String,
phone: json['phone'] as String,
role: json['role'] as String? ?? 'rider',
status: json['status'] as String? ?? 'active',
language: json['language'] as String? ?? 'ar',
name: json['name'] as String?,
rating: json['rating']?.toString(),
);
}
/// الملف ناقص ما لم يُدخل المستخدم اسمه — يقود شاشة إكمال الملف.
bool get needsProfile => name == null || name!.trim().isEmpty;
@override
List<Object?> get props => [id, tenantId, phone, role, status, language, name];
}
@@ -0,0 +1,57 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../api/api_exception.dart';
import 'app_user.dart';
import 'session_source.dart';
import 'session_state.dart';
/// حالة الجلسة على مستوى التطبيق — مصدر قرار التوجيه الوحيد
/// (`core/router.dart` يستمع إليها).
class SessionCubit extends Cubit<SessionState> {
SessionCubit(this._repo) : super(const SessionState());
final SessionSource _repo;
/// تُستدعى مرة عند الإقلاع من شاشة الـsplash.
Future<void> restore() async {
if (!_repo.hasSession) {
emit(const SessionState(status: SessionStatus.unauthenticated));
return;
}
try {
_apply(await _repo.me());
} on ApiException catch (e) {
// 401 هنا يعني رمز تحديث ميّت — الـinterceptor مسح المخزن أصلاً.
// أي خطأ آخر (شبكة) لا يُسقط الجلسة: المستخدم مسجّل، الشبكة هي الغائبة.
if (e.isUnauthorized) {
emit(const SessionState(status: SessionStatus.unauthenticated));
} else {
emit(const SessionState(status: SessionStatus.authenticated));
}
}
}
void onLoggedIn(AppUser user) => _apply(user);
void onProfileCompleted(AppUser user) => _apply(user);
Future<void> logout() async {
await _repo.logout();
emit(const SessionState(status: SessionStatus.unauthenticated));
}
/// انتهاء الجلسة من طبقة الشبكة (رمز تحديث ميّت) — لا يمرّ بالمستودع.
void onSessionExpired() {
emit(const SessionState(status: SessionStatus.unauthenticated));
}
void _apply(AppUser user) {
emit(SessionState(
status: user.needsProfile
? SessionStatus.needsProfile
: SessionStatus.authenticated,
user: user,
));
}
}
@@ -0,0 +1,13 @@
import 'app_user.dart';
/// ما تحتاجه الجلسة من طبقة المصادقة — **ولا شيء أكثر**.
///
/// `SessionCubit` حالةٌ على مستوى التطبيق (الراوتر والشاشات كلها تقرأها)،
/// فمحلّها `core/`. ولأن `core/` لا يجوز أن يستورد من `features/` كما لا
/// تستورد ميزةٌ من أخرى (docs/23 §1)، تُعرّف الحاجة هنا كواجهة ضيّقة
/// ويُنفّذها `AuthRepository` في ميزة المصادقة.
abstract interface class SessionSource {
Future<AppUser> me();
Future<void> logout();
bool get hasSession;
}
@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import 'app_user.dart';
enum SessionStatus {
/// قبل أن نعرف — تُعرض شاشة الإقلاع، ولا يُتّخذ قرار توجيه.
unknown,
authenticated,
/// مصادَق لكن بلا اسم — يُوجَّه لإكمال الملف قبل أي شاشة أخرى.
needsProfile,
unauthenticated,
}
class SessionState extends Equatable {
const SessionState({this.status = SessionStatus.unknown, this.user});
final SessionStatus status;
final AppUser? user;
bool get isReady => status != SessionStatus.unknown;
SessionState copyWith({SessionStatus? status, AppUser? user}) {
return SessionState(
status: status ?? this.status,
user: user ?? this.user,
);
}
@override
List<Object?> get props => [status, user];
}