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,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];
|
||||
}
|
||||
Reference in New Issue
Block a user