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
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# مُولَّد من ARB — لا يُحرَّر
lib/core/l10n/gen/
+7
View File
@@ -0,0 +1,7 @@
# توليد الترجمة القياسي (docs/26 §4): ARB + gen_l10n. لا نص مرئي داخل widget.
arb-dir: lib/core/l10n
template-arb-file: app_ar.arb
output-localization-file: app_localizations.dart
output-class: L10n
output-dir: lib/core/l10n/gen
nullable-getter: false
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'core/build_config.dart';
import 'core/design/theme.dart';
import 'core/di.dart';
import 'core/l10n/l10n.dart';
import 'core/router.dart';
import 'features/settings/cubit/settings_cubit.dart';
import 'features/settings/cubit/settings_state.dart';
class TripzApp extends StatelessWidget {
const TripzApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<SettingsCubit>(),
child: BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, settings) {
return MaterialApp.router(
title: BuildConfig.appName,
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
// الثيم يُبنى من مدخلين لا ثالث لهما (docs/26 §4).
theme: buildTheme(Brightness.light, settings.locale),
darkTheme: buildTheme(Brightness.dark, settings.locale),
themeMode: settings.themeMode,
// الاتجاه يتبع اللغة آلياً — لا `Directionality` مفروضة فوق كل
// شيء، وإلا انكسرت الإنجليزية (docs/26 §4).
locale: settings.locale,
supportedLocales: L10n.supportedLocales,
localizationsDelegates: const [
...L10n.localizationsDelegates,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// سقف تكبير النص 1.3 — وهو ما تُفحص عليه الشاشات (docs/26 §9).
// ما فوقه يكسر التخطيط بلا مكسب قرائي.
builder: (context, child) => MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.3,
child: child ?? const SizedBox.shrink(),
),
);
},
),
);
}
}
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,19 +0,0 @@
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(),
),
],
);
@@ -1,6 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/app_config.dart'; import '../config.dart';
import '../storage/token_store.dart'; import '../storage/token_store.dart';
import 'api_exception.dart'; import 'api_exception.dart';
import 'auth_interceptor.dart'; import 'auth_interceptor.dart';
@@ -2,7 +2,8 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/app_config.dart'; import '../build_config.dart';
import '../config.dart';
import '../storage/token_store.dart'; import '../storage/token_store.dart';
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه. /// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
@@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor {
RequestOptions options, RequestOptions options,
RequestInterceptorHandler handler, RequestInterceptorHandler handler,
) async { ) async {
options.headers['x-tenant-id'] = AppConfig.tenantSlug; options.headers['x-tenant-id'] = BuildConfig.tenantSlug;
options.headers['x-app-role'] = AppConfig.appRole; options.headers['x-app-role'] = AppConfig.appRole;
final deviceId = await _tokens.deviceId(); final deviceId = await _tokens.deviceId();
@@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor {
'/auth/refresh', '/auth/refresh',
data: {'refresh_token': refresh}, data: {'refresh_token': refresh},
options: Options(headers: { options: Options(headers: {
'x-tenant-id': AppConfig.tenantSlug, 'x-tenant-id': BuildConfig.tenantSlug,
'x-app-role': AppConfig.appRole, 'x-app-role': AppConfig.appRole,
'x-device-id': ?deviceId, 'x-device-id': ?deviceId,
}), }),
@@ -0,0 +1,40 @@
// ⚠️ ملف مُولَّد (docs/23 §1 — N3). لا يُحرَّر يدوياً؛ يُعاد توليده لكل
// مستأجر/طبقة عند البناء. القيم هنا هي القيم الافتراضية للمستأجر التجريبي.
import 'package:flutter/material.dart';
/// المتغيّر الوحيد بين المستأجرين: **لون · لوغو · اسم** (docs/26 §0).
/// أي شيء آخر يتغيّر بين مستأجرين = خطأ يُصحَّح.
class BuildConfig {
const BuildConfig._();
static const String appName = 'Tripz Driver';
static const String tenantSlug = 'siro';
/// اللون البذرة — منه تُشتقّ `ColorScheme` للوضعين.
static const Color seedColor = Color(0xFF1F6FEB);
static const String logoAsset = 'assets/images/logo.png';
static const String splashLogoAsset = 'assets/images/splash_logo.png';
}
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
/// من الـbinary عند إطفائها — خريطة في وقت التشغيل تُبقي الكود قابلاً
/// للاستخراج.
///
/// هذه طبقة **الطبقة المُباعة** (lite/pro/max). طبقة ثانية مستقلة تأتي من
/// `GET /tenant/config/:slug` وقت التشغيل (docs/38 §10) وتخفي ما اشتراه
/// المستأجر فعلاً — الاثنتان تتقاطعان: العَلَم `const` يقرّر ما يُبنى، وردّ
/// الخادم يقرّر ما يُعرض ممّا بُني.
class Features {
const Features._();
static const bool wallet = true;
static const bool chat = true;
static const bool calls = true;
static const bool rideTypes = true;
static const bool coupons = true;
static const bool geofence = false;
static const bool transit = false;
static const bool marketIntel = false;
}
@@ -1,11 +1,11 @@
/// إعداد البناء — كل قيمة هنا `const` تُمرَّر عبر `--dart-define` عند البناء. /// إعداد وقت التشغيل — يُمرَّر بـ`--dart-define` عند البناء (docs/23 §1).
/// ///
/// هذا هو أساس نموذج lite/pro/max (docs/37 §6.25): بناء مولَّد بأعلام ثابتة، /// ما يخصّ **هوية المستأجر البصرية** (لون · لوغو · اسم) يعيش في
/// لا فروع كود. القيم الافتراضية تشير للسيرفر التجريبي. /// `build_config.dart` المُولَّد، لا هنا.
class AppConfig { class AppConfig {
const AppConfig._(); const AppConfig._();
/// أصل الـAPI **مع** `/api` — كما في docs/38. /// أصل الـAPI **مع** `/api` (docs/38).
static const String apiBaseUrl = String.fromEnvironment( static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://tripz-api.intaleqapp.com/api', defaultValue: 'https://tripz-api.intaleqapp.com/api',
@@ -17,18 +17,19 @@ class AppConfig {
defaultValue: 'https://tripz-api.intaleqapp.com', 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). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.** /// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
static const String appRole = 'driver'; static const String appRole = 'driver';
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار /// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
/// حتى 401 يعني فشل طلب في منتصف رحلة. static const int otpLength = 4;
/// مهلة إعادة إرسال الرمز. الخادم يحدّ `send-otp` بثلاثة طلبات كل خمس
/// دقائق، فبلا عدّاد تنازلي يُحظر المستخدم بثلاث ضغطات (docs/39 §2).
static const Duration otpResendCooldown = Duration(seconds: 60);
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)،
/// فالانتظار حتى 401 يعني فشل طلب في منتصف رحلة.
static const Duration tokenRefreshLeeway = Duration(seconds: 60); static const Duration tokenRefreshLeeway = Duration(seconds: 60);
static const Duration connectTimeout = Duration(seconds: 20); static const Duration connectTimeout = Duration(seconds: 20);
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../build_config.dart';
import 'tokens.dart';
import 'tripz_colors.dart';
import 'typography.dart';
/// دالة بناء الثيم الوحيدة (docs/26 §4): مدخلان لا ثالث لهما.
///
/// الاتجاه (RTL/LTR) **لا يُفرض هنا** — `MaterialApp` يشتقّه من الـlocale
/// وحده. فرض `Directionality.rtl` يكسر الإنجليزية (docs/26 §4).
ThemeData buildTheme(Brightness brightness, Locale locale) {
final scheme = ColorScheme.fromSeed(
seedColor: BuildConfig.seedColor,
brightness: brightness,
);
final isDark = brightness == Brightness.dark;
final texts = buildTextTheme(brightness, locale);
final tripz = isDark ? TripzColors.dark : TripzColors.light;
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
brightness: brightness,
fontFamily: AppFonts.familyFor(locale),
textTheme: texts,
scaffoldBackgroundColor: scheme.surface,
extensions: [tripz],
// AppBar مسطّح بلا ظل (docs/26 §6).
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: texts.titleLarge?.copyWith(color: scheme.onSurface),
),
cardTheme: CardThemeData(
color: tripz.surfaceRaised,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: Radii.card_,
side: BorderSide(color: scheme.outlineVariant),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
showDragHandle: true,
shape: const RoundedRectangleBorder(borderRadius: Radii.sheetTop),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark ? scheme.surfaceContainerHigh : scheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
),
border: _fieldBorder(scheme.outlineVariant),
enabledBorder: _fieldBorder(scheme.outlineVariant),
focusedBorder: _fieldBorder(scheme.primary, width: 1.5),
errorBorder: _fieldBorder(scheme.error),
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
errorStyle: texts.bodySmall?.copyWith(color: scheme.error),
),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
space: 1,
thickness: 1,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: scheme.inverseSurface,
contentTextStyle:
texts.bodyMedium?.copyWith(color: scheme.onInverseSurface),
shape: const RoundedRectangleBorder(borderRadius: Radii.card_),
),
);
}
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) {
return OutlineInputBorder(
borderRadius: Radii.field_,
borderSide: BorderSide(color: color, width: width),
);
}
@@ -0,0 +1,69 @@
import 'package:flutter/widgets.dart';
/// الرموز الثابتة (docs/26 §3). **الشاشة لا تخترع رقماً** — لا مسافة ولا زاوية
/// ولا مدّة خارج هذا الملف.
class Space {
const Space._();
/// سلّم مضاعفات الأربعة.
static const double xxs = 4;
static const double xs = 8;
static const double sm = 12;
/// حشوة الصفحة الافتراضية.
static const double md = 16;
static const double lg = 24;
static const double xl = 32;
static const double xxl = 48;
static const EdgeInsets page = EdgeInsets.all(md);
static const EdgeInsets pageH = EdgeInsets.symmetric(horizontal: md);
}
class Radii {
const Radii._();
/// حقول وشارات.
static const double field = 8;
/// أزرار وبطاقات.
static const double card = 12;
/// الأوراق السفلية.
static const double sheet = 24;
static const BorderRadius field_ = BorderRadius.all(Radius.circular(field));
static const BorderRadius card_ = BorderRadius.all(Radius.circular(card));
static const BorderRadius sheetTop = BorderRadius.vertical(
top: Radius.circular(sheet),
);
}
class Motion {
const Motion._();
/// استجابة لمسة.
static const Duration tap = Duration(milliseconds: 120);
/// انتقال ضمن الشاشة.
static const Duration inScreen = Duration(milliseconds: 240);
/// ورقة سفلية أو صفحة.
static const Duration page = Duration(milliseconds: 400);
static const Curve curve = Curves.easeOutCubic;
}
class Sizes {
const Sizes._();
static const double iconSm = 20;
static const double icon = 24;
static const double iconLg = 32;
/// ارتفاع زر الـCTA وحقل الإدخال.
static const double control = 52;
/// أدنى هدف لمس مقبول.
static const double touchTarget = 48;
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2).
///
/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها،
/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر
/// كل المستأجرين كما يفرض عقد §0.
@immutable
class TripzColors extends ThemeExtension<TripzColors> {
const TripzColors({
required this.success,
required this.onSuccess,
required this.warning,
required this.onWarning,
required this.danger,
required this.onDanger,
required this.info,
required this.surfaceRaised,
required this.mapRoute,
required this.mapOrigin,
required this.mapDestination,
required this.skeleton,
});
final Color success;
final Color onSuccess;
final Color warning;
final Color onWarning;
final Color danger;
final Color onDanger;
final Color info;
/// السطح المرتفع — في الليلي ارتفاعٌ **بالسطوع لا بالظل** (docs/26 §2).
final Color surfaceRaised;
final Color mapRoute;
final Color mapOrigin;
final Color mapDestination;
/// لون هيكل التحميل (skeleton) — لا spinner فارغ (docs/26 §8.4).
final Color skeleton;
static const light = TripzColors(
success: Color(0xFF1B8A5A),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFB4690E),
onWarning: Color(0xFFFFFFFF),
danger: Color(0xFFC5303B),
onDanger: Color(0xFFFFFFFF),
info: Color(0xFF2563A8),
surfaceRaised: Color(0xFFFFFFFF),
mapRoute: Color(0xFF1F6FEB),
mapOrigin: Color(0xFF1B8A5A),
mapDestination: Color(0xFFC5303B),
skeleton: Color(0xFFE6E8EC),
);
static const dark = TripzColors(
success: Color(0xFF4ECB8D),
onSuccess: Color(0xFF00281A),
warning: Color(0xFFE9A63B),
onWarning: Color(0xFF2A1A00),
danger: Color(0xFFF2707A),
onDanger: Color(0xFF33000A),
info: Color(0xFF7FB2F0),
// ليس أسود صرفاً — رمادي داكن متدرّج.
surfaceRaised: Color(0xFF20232A),
mapRoute: Color(0xFF5B9BFF),
mapOrigin: Color(0xFF4ECB8D),
mapDestination: Color(0xFFF2707A),
skeleton: Color(0xFF2C3038),
);
@override
TripzColors copyWith({
Color? success,
Color? onSuccess,
Color? warning,
Color? onWarning,
Color? danger,
Color? onDanger,
Color? info,
Color? surfaceRaised,
Color? mapRoute,
Color? mapOrigin,
Color? mapDestination,
Color? skeleton,
}) {
return TripzColors(
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
warning: warning ?? this.warning,
onWarning: onWarning ?? this.onWarning,
danger: danger ?? this.danger,
onDanger: onDanger ?? this.onDanger,
info: info ?? this.info,
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
mapRoute: mapRoute ?? this.mapRoute,
mapOrigin: mapOrigin ?? this.mapOrigin,
mapDestination: mapDestination ?? this.mapDestination,
skeleton: skeleton ?? this.skeleton,
);
}
@override
TripzColors lerp(ThemeExtension<TripzColors>? other, double t) {
if (other is! TripzColors) return this;
return TripzColors(
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
warning: Color.lerp(warning, other.warning, t)!,
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
danger: Color.lerp(danger, other.danger, t)!,
onDanger: Color.lerp(onDanger, other.onDanger, t)!,
info: Color.lerp(info, other.info, t)!,
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
mapRoute: Color.lerp(mapRoute, other.mapRoute, t)!,
mapOrigin: Color.lerp(mapOrigin, other.mapOrigin, t)!,
mapDestination: Color.lerp(mapDestination, other.mapDestination, t)!,
skeleton: Color.lerp(skeleton, other.skeleton, t)!,
);
}
}
extension TripzColorsX on BuildContext {
TripzColors get tripzColors => Theme.of(this).extension<TripzColors>()!;
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get texts => Theme.of(this).textTheme;
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// الخطوط (docs/26 §1) — ملفات محلية في `assets/fonts/`. حزمة `google_fonts`
/// ممنوعة: الشكل يجب أن يكون حتمياً من أول إقلاع بلا شبكة.
class AppFonts {
const AppFonts._();
static const arabic = 'IBMPlexSansArabic';
static const latin = 'Inter';
/// الخط يتبع اللغة، والآخر احتياط — كي لا تنكسر كلمة عربية داخل جملة
/// إنجليزية ولا العكس.
static String familyFor(Locale locale) =>
locale.languageCode == 'ar' ? arabic : latin;
static List<String> fallbackFor(Locale locale) =>
locale.languageCode == 'ar' ? const [latin] : const [arabic];
}
/// أرقام tabular: تمنع «رقص» الأعمدة حين تتغيّر القيمة — للأسعار والعدّادات
/// والمسافات الحيّة (docs/26 §1). يُغني عن خط `digit` المتقاعد.
const tabularFigures = [FontFeature.tabularFigures()];
TextTheme buildTextTheme(Brightness brightness, Locale locale) {
final base = brightness == Brightness.dark
? Typography.material2021().white
: Typography.material2021().black;
final family = AppFonts.familyFor(locale);
final fallback = AppFonts.fallbackFor(locale);
return base
.apply(fontFamily: family, fontFamilyFallback: fallback)
.copyWith(
displaySmall: base.displaySmall?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w700,
),
headlineMedium: base.headlineMedium?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w700,
),
headlineSmall: base.headlineSmall?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
titleLarge: base.titleLarge?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
titleMedium: base.titleMedium?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
labelLarge: base.labelLarge?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
);
}
/// نمط الأرقام الحيّة — يُستعمل للأجرة والرصيد والعدّاد التنازلي.
/// دائماً Inter بأرقام tabular، مهما كانت لغة الواجهة (docs/26 §5: الأرقام
/// لاتينية في اللغتين).
TextStyle numericStyle(TextStyle? base) => (base ?? const TextStyle()).copyWith(
fontFamily: AppFonts.latin,
fontFeatures: tabularFigures,
);
+66
View File
@@ -0,0 +1,66 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../features/auth/cubit/login_cubit.dart';
import '../features/auth/data/auth_repository.dart';
import '../features/settings/cubit/settings_cubit.dart';
import 'api/api_client.dart';
import 'session/session_cubit.dart';
import 'storage/token_store.dart';
final sl = GetIt.instance;
/// نقطة الإقفال الوحيدة (docs/23 §5): الميزة المطفأة **لا تُسجَّل**، بدل
/// `if` مبعثرة في الشاشات.
Future<void> setupInjector() async {
final prefs = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(prefs);
const secure = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final tokens = TokenStore(secure);
await tokens.load();
await _ensureDeviceId(tokens);
sl.registerSingleton<TokenStore>(tokens);
sl.registerSingleton<SettingsCubit>(SettingsCubit(prefs));
// انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر
// يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor.
sl.registerSingleton<ApiClient>(
ApiClient.create(
tokens: tokens,
onSessionExpired: () async => sl<SessionCubit>().onSessionExpired(),
),
);
sl.registerSingleton<AuthRepository>(AuthRepository(sl(), sl()));
sl.registerSingleton<SessionCubit>(SessionCubit(sl()));
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
}
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل
/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**.
Future<void> _ensureDeviceId(TokenStore tokens) async {
if (await tokens.deviceId() != null) return;
final info = DeviceInfoPlugin();
String? id;
try {
if (defaultTargetPlatform == TargetPlatform.android) {
id = (await info.androidInfo).id;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
id = (await info.iosInfo).identifierForVendor;
}
} catch (_) {
// منصّة لا تجيب — المعرّف الفارغ أفضل من إقلاع فاشل.
}
if (id != null && id.isNotEmpty) await tokens.setDeviceId(id);
}
-31
View File
@@ -1,31 +0,0 @@
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 {},
),
);
}
+67
View File
@@ -0,0 +1,67 @@
{
"@@locale": "ar",
"appTitle": "Tripz",
"actionRetry": "أعد المحاولة",
"actionCancel": "إلغاء",
"actionContinue": "متابعة",
"actionSave": "حفظ",
"actionOpenSettings": "افتح الإعدادات",
"errorGeneric": "حدث خطأ غير متوقّع",
"errorNetwork": "تعذّر الاتصال بالخادم",
"errorTimeout": "انتهت مهلة الاتصال",
"emptyDefault": "لا يوجد شيء هنا بعد",
"splashRider": "تطبيق الراكب",
"splashDriver": "تطبيق السائق",
"agreementTitle": "شروط الاستخدام",
"agreementLead": "قبل أن نبدأ، اقرأ الشروط ووافق عليها.",
"agreementCheckbox": "قرأت الشروط وأوافق عليها",
"agreementAccept": "أوافق وأتابع",
"permissionTitle": "نحتاج موقعك",
"permissionLead": "الموقع هو ما يجعل الرحلة ممكنة: به نعرف أين أنت وأين السائق.",
"permissionAllow": "السماح بالوصول للموقع",
"permissionDeniedForever": "رفضتَ الإذن نهائياً. افتح الإعدادات وفعّله يدوياً ثم عُد.",
"phoneTitle": "أهلاً بك، كابتن",
"phoneLead": "أدخل رقم هاتفك، ونرسل لك رمز تحقّق.",
"phoneLabel": "رقم الهاتف",
"phoneHint": "بلا الصفر في البداية",
"phoneSend": "أرسل رمز التحقّق",
"phoneErrEmpty": "أدخل رقم هاتفك",
"phoneErrLeadingZero": "أدخل الرقم بلا الصفر في البداية",
"phoneErrShort": "الرقم قصير جداً",
"otpTitle": "أدخل رمز التحقّق",
"otpLead": "أرسلنا رمزاً من أربع خانات إلى {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"otpVerify": "تحقّق وتابع",
"otpResend": "إعادة إرسال الرمز",
"otpResendIn": "إعادة الإرسال بعد {seconds} ثانية",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"otpErrIncomplete": "أدخل الرمز كاملاً",
"otpErrInvalid": "الرمز غير صحيح أو انتهت صلاحيته",
"otpErrTooManyAttempts": "محاولات كثيرة. اطلب رمزاً جديداً.",
"otpErrRateLimited": "طلبتَ الرمز مرات كثيرة. انتظر قليلاً ثم أعد المحاولة.",
"otpChangeNumber": "تعديل الرقم",
"profileTitle": "أكمل ملفك",
"profileLead": "اسمك يظهر للراكب عند الرحلة.",
"profileNameLabel": "الاسم",
"profileNameErrEmpty": "أدخل اسمك",
"sessionExpired": "انتهت جلستك. سجّل الدخول من جديد.",
"settingsTitle": "الإعدادات",
"settingsTheme": "المظهر",
"settingsThemeSystem": "حسب النظام",
"settingsThemeLight": "فاتح",
"settingsThemeDark": "داكن",
"settingsLanguage": "اللغة",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
+67
View File
@@ -0,0 +1,67 @@
{
"@@locale": "en",
"appTitle": "Tripz",
"actionRetry": "Try again",
"actionCancel": "Cancel",
"actionContinue": "Continue",
"actionSave": "Save",
"actionOpenSettings": "Open settings",
"errorGeneric": "Something went wrong",
"errorNetwork": "Could not reach the server",
"errorTimeout": "The connection timed out",
"emptyDefault": "Nothing here yet",
"splashRider": "Rider app",
"splashDriver": "Driver app",
"agreementTitle": "Terms of use",
"agreementLead": "Before we start, read the terms and accept them.",
"agreementCheckbox": "I have read and accept the terms",
"agreementAccept": "Accept and continue",
"permissionTitle": "We need your location",
"permissionLead": "Location is what makes a trip possible: it tells us where you are and where your driver is.",
"permissionAllow": "Allow location access",
"permissionDeniedForever": "You denied the permission permanently. Open settings, enable it, then come back.",
"phoneTitle": "Welcome, captain",
"phoneLead": "Enter your phone number and we'll send you a verification code.",
"phoneLabel": "Phone number",
"phoneHint": "without the leading zero",
"phoneSend": "Send verification code",
"phoneErrEmpty": "Enter your phone number",
"phoneErrLeadingZero": "Enter the number without the leading zero",
"phoneErrShort": "That number is too short",
"otpTitle": "Enter your code",
"otpLead": "We sent a 4-digit code to {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"otpVerify": "Verify and continue",
"otpResend": "Resend the code",
"otpResendIn": "Resend in {seconds}s",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"otpErrIncomplete": "Enter the full code",
"otpErrInvalid": "That code is wrong or expired",
"otpErrTooManyAttempts": "Too many attempts. Request a new code.",
"otpErrRateLimited": "You requested the code too many times. Wait a moment and try again.",
"otpChangeNumber": "Change number",
"profileTitle": "Complete your profile",
"profileLead": "Your name is shown to the rider during a trip.",
"profileNameLabel": "Name",
"profileNameErrEmpty": "Enter your name",
"sessionExpired": "Your session ended. Please sign in again.",
"settingsTitle": "Settings",
"settingsTheme": "Appearance",
"settingsThemeSystem": "Follow system",
"settingsThemeLight": "Light",
"settingsThemeDark": "Dark",
"settingsLanguage": "Language",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
import 'gen/app_localizations.dart';
export 'gen/app_localizations.dart';
/// كل نص مرئي يمرّ من هنا: `context.l10n.phoneTitle` (docs/26 §4).
extension L10nX on BuildContext {
L10n get l10n => L10n.of(this);
}
+78
View File
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'session/session_cubit.dart';
import 'session/session_state.dart';
import '../features/auth/view/login_page.dart';
import '../features/auth/view/profile_page.dart';
import '../features/home/view/home_page.dart';
import '../features/splash/view/splash_page.dart';
import 'di.dart';
class Routes {
const Routes._();
static const splash = '/';
static const login = '/login';
static const profile = '/profile';
static const home = '/home';
}
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
/// موزّعة في الشاشات، فلا تتناقض حالتان.
final appRouter = GoRouter(
initialLocation: Routes.splash,
refreshListenable: _CubitListenable(sl<SessionCubit>().stream),
redirect: (context, state) {
final session = sl<SessionCubit>().state;
final loc = state.matchedLocation;
// قبل أن نعرف: نبقى على الإقلاع ولا نقرّر شيئاً.
if (!session.isReady) return loc == Routes.splash ? null : Routes.splash;
return switch (session.status) {
SessionStatus.unauthenticated =>
loc == Routes.login ? null : Routes.login,
SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile,
SessionStatus.authenticated =>
(loc == Routes.login || loc == Routes.profile || loc == Routes.splash)
? Routes.home
: null,
SessionStatus.unknown => Routes.splash,
};
},
routes: [
GoRoute(
path: Routes.splash,
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: Routes.login,
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: Routes.profile,
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const HomePage(),
),
],
);
/// جسر بين تيّار الـCubit و`refreshListenable` الذي يطلبه go_router.
class _CubitListenable extends ChangeNotifier {
_CubitListenable(Stream<dynamic> stream) {
_sub = stream.listen((_) => notifyListeners());
}
late final StreamSubscription<dynamic> _sub;
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
@@ -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];
}
@@ -1,26 +0,0 @@
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,54 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الفراغ: رسالة **تدلّ على الفعل التالي** لا مجرّد «لا يوجد»
/// (docs/26 §8.4).
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = Icons.inbox_rounded,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: Sizes.iconLg,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.emptyDefault,
textAlign: TextAlign.center,
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(label: actionLabel!, onPressed: onAction),
],
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الخطأ: رسالة **بالعربية** مع «أعد المحاولة» — لا شاشة صامتة
/// (docs/26 §8.4). الرسالة تأتي جاهزة من الـCubit (docs/23 §3).
class ErrorView extends StatelessWidget {
const ErrorView({super.key, this.message, this.onRetry});
final String? message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: Sizes.iconLg,
color: context.tripzColors.danger,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.errorGeneric,
textAlign: TextAlign.center,
style: context.texts.bodyMedium,
),
if (onRetry != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(
label: context.l10n.actionRetry,
onPressed: onRetry,
),
],
],
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
/// هيكل تحميل — **لا spinner فارغ** (docs/26 §8.4).
///
/// النبض `AnimatedBuilder` على `Opacity` وحدها: لا يُعاد بناء الشجرة تحته،
/// وهذا ما يفرق بين skeleton رخيص وآخر يأكل إطارات.
class Skeleton extends StatefulWidget {
const Skeleton({
super.key,
this.width,
this.height = 16,
this.radius = Radii.field,
});
/// ثلاثة أسطر بأطوال متفاوتة — الشكل الافتراضي لأي قائمة تُحمَّل.
const Skeleton.lines({super.key})
: width = null,
height = -1,
radius = Radii.field;
final double? width;
final double height;
final double radius;
@override
State<Skeleton> createState() => _SkeletonState();
}
class _SkeletonState extends State<Skeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = context.tripzColors.skeleton;
final child = widget.height < 0
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bar(color, double.infinity),
const SizedBox(height: Space.sm),
_bar(color, 220),
const SizedBox(height: Space.sm),
_bar(color, 140),
],
)
: _bar(color, widget.width ?? double.infinity, widget.height);
// تُحترم إعدادات تقليل الحركة في النظام (docs/26 §8.5).
if (MediaQuery.disableAnimationsOf(context)) return child;
return AnimatedBuilder(
animation: _controller,
builder: (_, inner) => Opacity(
opacity: 0.45 + (_controller.value * 0.35),
child: inner,
),
child: child,
);
}
Widget _bar(Color color, double width, [double height = 16]) => Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(widget.radius),
),
);
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
enum BannerTone { info, success, warning, danger }
/// شريط الحالة الحيّ (docs/26 §7) — حالة الرحلة، تحذير الرصيد، انقطاع الشبكة.
/// اللون من الأدوار الدلالية لا من hex.
class StatusBanner extends StatelessWidget {
const StatusBanner({
super.key,
required this.message,
this.tone = BannerTone.info,
this.icon,
this.trailing,
});
final String message;
final BannerTone tone;
final IconData? icon;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final c = context.tripzColors;
final color = switch (tone) {
BannerTone.info => c.info,
BannerTone.success => c.success,
BannerTone.warning => c.warning,
BannerTone.danger => c.danger,
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.sm,
vertical: Space.xs,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: Radii.field_,
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
children: [
Icon(icon ?? Icons.info_outline_rounded, size: Sizes.iconSm, color: color),
const SizedBox(width: Space.xs),
Expanded(
child: Text(message, style: context.texts.bodySmall),
),
?trailing,
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
enum _ButtonKind { primary, secondary, text }
/// الزر الموحّد (docs/26 §7). ارتفاع 52، وحالة تحميل **داخلية**: الشاشة
/// تمرّر `loading: true` ولا تستبدل الزر بمؤشّر — فلا يقفز التخطيط.
class TripzButton extends StatelessWidget {
const TripzButton.primary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = true,
}) : _kind = _ButtonKind.primary;
const TripzButton.secondary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.secondary;
const TripzButton.text({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.text;
final String label;
final VoidCallback? onPressed;
final bool loading;
final IconData? icon;
final bool expanded;
final _ButtonKind _kind;
@override
Widget build(BuildContext context) {
// أثناء التحميل يُعطَّل الزر — يمنع الإرسال المزدوج بلا حارس في كل شاشة.
final onTap = loading ? null : onPressed;
final child = _Content(label: label, icon: icon, loading: loading);
final button = switch (_kind) {
_ButtonKind.primary => FilledButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.secondary => OutlinedButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.text => TextButton(
onPressed: onTap,
style: _style(context),
child: child,
),
};
if (!expanded) return button;
return SizedBox(width: double.infinity, child: button);
}
ButtonStyle _style(BuildContext context) => ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size(0, Sizes.control),
),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: Radii.card_),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: Space.lg),
),
textStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.labelLarge,
),
);
}
class _Content extends StatelessWidget {
const _Content({required this.label, this.icon, required this.loading});
final String label;
final IconData? icon;
final bool loading;
@override
Widget build(BuildContext context) {
if (loading) {
return const SizedBox(
width: Sizes.iconSm,
height: Sizes.iconSm,
child: CircularProgressIndicator(strokeWidth: 2.2),
);
}
if (icon == null) return Text(label);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Sizes.iconSm),
const SizedBox(width: Space.xs),
Text(label),
],
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// بطاقة موحّدة (docs/26 §7): الرحلة · السائق · المحفظة.
/// الحدّ لا الظل — «الوضوح قبل الزينة» (docs/26 §8.2).
class TripzCard extends StatelessWidget {
const TripzCard({
super.key,
required this.child,
this.onTap,
this.padding = Space.page,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Card(
clipBehavior: Clip.antiAlias,
child: onTap == null
? content
: InkWell(onTap: onTap, child: content),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل رمز التحقّق — أربع خانات (طول رمز تريبز، docs/38 §2).
///
/// حقل حقيقي واحد تحت خانات معروضة: يُبقي اللصق والإكمال التلقائي من الرسالة
/// (`AutofillHints.oneTimeCode`) يعملان — وهو ما يكسره تقسيمُه إلى أربعة
/// حقول منفصلة. يُستدعى [onCompleted] عند اكتمال الطول.
class TripzOtpField extends StatefulWidget {
const TripzOtpField({
super.key,
required this.controller,
this.length = 4,
this.onCompleted,
this.enabled = true,
this.hasError = false,
});
final TextEditingController controller;
final int length;
final ValueChanged<String>? onCompleted;
final bool enabled;
final bool hasError;
@override
State<TripzOtpField> createState() => _TripzOtpFieldState();
}
class _TripzOtpFieldState extends State<TripzOtpField> {
final _focus = FocusNode();
@override
void dispose() {
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
// الحقل الفعلي — شفّاف بلا حجم بصري، يستقبل الكيبورد واللصق.
SizedBox(
height: Sizes.control,
child: Opacity(
opacity: 0,
child: TextField(
controller: widget.controller,
focusNode: _focus,
enabled: widget.enabled,
autofocus: true,
keyboardType: TextInputType.number,
maxLength: widget.length,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.oneTimeCode],
onChanged: (v) {
if (v.length == widget.length) widget.onCompleted?.call(v);
},
),
),
),
GestureDetector(
onTap: () => _focus.requestFocus(),
behavior: HitTestBehavior.opaque,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller,
builder: (context, value, _) => Row(
mainAxisAlignment: MainAxisAlignment.center,
textDirection: TextDirection.ltr,
children: List.generate(widget.length, (i) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Space.xxs),
child: _Box(
char: i < value.text.length ? value.text[i] : null,
focused: _focus.hasFocus && i == value.text.length,
hasError: widget.hasError,
),
);
}),
),
),
),
],
);
}
}
class _Box extends StatelessWidget {
const _Box({
required this.char,
required this.focused,
required this.hasError,
});
final String? char;
final bool focused;
final bool hasError;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final borderColor = hasError
? scheme.error
: focused
? scheme.primary
: scheme.outlineVariant;
return AnimatedContainer(
duration: Motion.tap,
curve: Motion.curve,
width: 56,
height: Sizes.control,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: Radii.field_,
border: Border.all(
color: borderColor,
width: focused || hasError ? 1.5 : 1,
),
),
child: Text(
char ?? '',
style: numericStyle(Theme.of(context).textTheme.headlineSmall),
),
);
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import 'empty_view.dart';
import 'error_view.dart';
import 'skeleton.dart';
import 'view_status.dart';
/// نقطة التنظيم المركزية (docs/26 §6). **ممنوع `Scaffold` خام في
/// `features/`.**
///
/// هو ما يحوّل `status` القادم من الـCubit إلى skeleton/خطأ/فراغ/محتوى — فلا
/// تكتب أي شاشة `if (loading)`، وتغيير شكل «التحميل» في التطبيق كله يصير
/// سطراً واحداً هنا.
class TripzScaffold extends StatelessWidget {
const TripzScaffold({
super.key,
required this.child,
this.title,
this.status = ViewStatus.success,
this.error,
this.onRetry,
this.empty,
this.loading,
this.bottomAction,
this.actions,
this.leading,
this.showAppBar = true,
this.padded = true,
this.resizeToAvoidBottomInset = true,
});
/// المحتوى — يُبنى **عند النجاح فقط**.
final Widget child;
final String? title;
final ViewStatus status;
/// رسالة الخطأ كما أصدرها الـCubit، بلا إعادة صياغة في الواجهة.
final String? error;
final VoidCallback? onRetry;
/// شكل الفراغ الخاص بالشاشة — وإلا فالافتراضي.
final Widget? empty;
/// شكل التحميل الخاص بالشاشة — وإلا فهيكل ثلاثة أسطر.
final Widget? loading;
/// خانة الـCTA السفلية: ترتفع فوق الكيبورد، وتحترم المنطقة الآمنة.
final Widget? bottomAction;
final List<Widget>? actions;
final Widget? leading;
final bool showAppBar;
final bool padded;
final bool resizeToAvoidBottomInset;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: !showAppBar
? null
: AppBar(
title: title == null ? null : Text(title!),
actions: actions,
leading: leading,
),
body: SafeArea(
top: !showAppBar,
child: Padding(
padding: padded ? Space.page : EdgeInsets.zero,
child: _Body(
status: status,
error: error,
onRetry: onRetry,
empty: empty,
loading: loading,
child: child,
),
),
),
bottomNavigationBar: bottomAction == null
? null
: SafeArea(
top: false,
child: Padding(
// ‏viewInsets يرفع الـCTA فوق الكيبورد بدل أن يختفي تحته.
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: bottomAction,
),
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.status,
required this.error,
required this.onRetry,
required this.empty,
required this.loading,
required this.child,
});
final ViewStatus status;
final String? error;
final VoidCallback? onRetry;
final Widget? empty;
final Widget? loading;
final Widget child;
@override
Widget build(BuildContext context) {
return switch (status) {
ViewStatus.idle ||
ViewStatus.loading =>
loading ?? const Skeleton.lines(),
ViewStatus.error => ErrorView(message: error, onRetry: onRetry),
ViewStatus.empty => empty ?? const EmptyView(),
ViewStatus.success => child,
};
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// الورقة السفلية — **الحوار الافتراضي في التطبيق sheet لا dialog**
/// (docs/26 §7). المقبض والزوايا من الثيم، فلا تُعاد كتابتهما في كل نداء.
class TripzSheet extends StatelessWidget {
const TripzSheet({
super.key,
required this.child,
this.title,
this.bottomAction,
});
final Widget child;
final String? title;
final Widget? bottomAction;
/// [dismissible] = false للحالات التي لا تُغلق بالسحب — مثل شاشة البحث عن
/// سائق: تُغلق بزر الإلغاء وحده كي لا يزيحها المستخدم بالخطأ (docs/39 §3).
static Future<T?> show<T>(
BuildContext context, {
required Widget child,
String? title,
Widget? bottomAction,
bool dismissible = true,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
isDismissible: dismissible,
enableDrag: dismissible,
showDragHandle: dismissible,
builder: (_) => TripzSheet(
title: title,
bottomAction: bottomAction,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null) ...[
Text(title!, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: Space.md),
],
Flexible(child: child),
if (bottomAction != null) ...[
const SizedBox(height: Space.md),
bottomAction!,
],
],
),
),
);
}
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل الإدخال الموحّد (docs/26 §7): نصّ · هاتف · بحث.
///
/// رسالة الخطأ تحته بالعربية، والأرقام لاتينية tabular دائماً — رقم الهاتف
/// ورمز التحقّق لا «يرقصان» أثناء الكتابة.
class TripzTextField extends StatelessWidget {
const TripzTextField({
super.key,
this.controller,
this.label,
this.hint,
this.errorText,
this.keyboardType,
this.textInputAction,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.enabled = true,
this.maxLength,
this.prefixIcon,
this.suffix,
this.inputFormatters,
this.autofillHints,
this.numeric = false,
});
/// حقل هاتف: أرقام فقط، لوحة مفاتيح هاتف، إكمال تلقائي من النظام.
factory TripzTextField.phone({
Key? key,
TextEditingController? controller,
String? label,
String? hint,
String? errorText,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
bool enabled = true,
}) {
return TripzTextField(
key: key,
controller: controller,
label: label,
hint: hint,
errorText: errorText,
enabled: enabled,
numeric: true,
autofocus: true,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: 15,
prefixIcon: Icons.phone_outlined,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.telephoneNumber],
);
}
final TextEditingController? controller;
final String? label;
final String? hint;
final String? errorText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
final bool enabled;
final int? maxLength;
final IconData? prefixIcon;
final Widget? suffix;
final List<TextInputFormatter>? inputFormatters;
final Iterable<String>? autofillHints;
final bool numeric;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyLarge;
return TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
keyboardType: keyboardType,
textInputAction: textInputAction,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: maxLength,
inputFormatters: inputFormatters,
autofillHints: autofillHints,
style: numeric ? numericStyle(base) : base,
// الأرقام تُقرأ يساراً-ليميناً حتى في واجهة عربية.
textDirection: numeric ? TextDirection.ltr : null,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
counterText: '',
prefixIcon: prefixIcon == null
? null
: Icon(prefixIcon, size: Sizes.iconSm),
suffixIcon: suffix,
),
);
}
}
@@ -0,0 +1,22 @@
/// حالة العرض الموحّدة التي يُصدرها كل Cubit (docs/23 §3) ويستهلكها
/// `TripzScaffold` (docs/26 §6).
///
/// هذا ما يجعل نظام التصميم متوافقاً مع Cubit **بالبنية لا بالاتفاق**:
/// الشاشة لا تكتب `if (loading)` أبداً.
enum ViewStatus {
/// لم يبدأ شيء بعد — يُعرض كتحميل لأن المستخدم لا يفرّق.
idle,
loading,
success,
/// نجح النداء ولا بيانات — يُعرض `EmptyView`.
empty,
error,
}
extension ViewStatusX on ViewStatus {
bool get isLoading => this == ViewStatus.idle || this == ViewStatus.loading;
bool get isError => this == ViewStatus.error;
bool get isEmpty => this == ViewStatus.empty;
bool get isSuccess => this == ViewStatus.success;
}
@@ -0,0 +1,38 @@
import '../../../core/api/api_exception.dart';
/// سبب الفشل كـ**رمز** لا كنصّ.
///
/// docs/23 §3 يقول إن الـCubit يحوّل خطأ الشبكة إلى رسالة عربية؛ وdocs/26 §4
/// (الأحدث، وهو الذي «يفعّل قاعدة docs/23 §2 التي كانت بلا أداة») يوجب مرور
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
/// الشاشة بلا منطق، والنص بلغتين.
enum AuthFailure {
/// رمز خاطئ أو منتهي الصلاحية.
invalidCode,
/// تجاوز عدد المحاولات — الخادم يُبطل الرمز فوراً (docs/38 §2).
tooManyAttempts,
/// تجاوز حدّ طلب الرمز: ثلاثة كل خمس دقائق.
rateLimited,
network,
timeout,
server,
}
extension AuthFailureX on ApiException {
AuthFailure toAuthFailure() {
if (isRateLimited) return AuthFailure.rateLimited;
if (kind == ApiErrorKind.network) return AuthFailure.network;
if (kind == ApiErrorKind.timeout) return AuthFailure.timeout;
if (isUnauthorized) {
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
return message.contains('Too many')
? AuthFailure.tooManyAttempts
: AuthFailure.invalidCode;
}
return AuthFailure.server;
}
}
@@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/config.dart';
import '../data/auth_repository.dart';
import 'auth_failure.dart';
import 'login_state.dart';
import 'phone_error.dart';
/// تدفّق الدخول كاملاً: الشروط ← إذن الموقع ← الهاتف ← الرمز.
///
/// الـCubit لا يعرف Flutter: لا `BuildContext` ولا تنقّل ولا `SnackBar`
/// (docs/23 §3). يُصدر حالة، والواجهة تتصرّف.
class LoginCubit extends Cubit<LoginState> {
LoginCubit(this._repo, this._prefs) : super(const LoginState()) {
_resolveInitialStep();
}
final AuthRepository _repo;
final SharedPreferences _prefs;
static const _kAgreed = 'auth.agreed_terms';
Timer? _resendTimer;
@override
Future<void> close() {
_resendTimer?.cancel();
return super.close();
}
// ── البوابتان ──────────────────────────────────────────────────────────
void _resolveInitialStep() {
if (_prefs.getBool(_kAgreed) != true) {
emit(state.copyWith(step: LoginStep.agreement));
return;
}
emit(state.copyWith(step: LoginStep.permission, agreed: true));
unawaited(checkLocationPermission());
}
void toggleAgreement(bool value) => emit(state.copyWith(agreed: value));
Future<void> acceptAgreement() async {
if (!state.agreed) return;
await _prefs.setBool(_kAgreed, true);
emit(state.copyWith(step: LoginStep.permission));
await checkLocationPermission();
}
/// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى
/// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2).
Future<void> checkLocationPermission() async {
final status = await Permission.locationWhenInUse.status;
if (status.isGranted || status.isLimited) {
emit(state.copyWith(step: LoginStep.phone));
return;
}
emit(state.copyWith(
permissionPermanentlyDenied: status.isPermanentlyDenied,
));
}
Future<void> requestLocationPermission() async {
final status = await Permission.locationWhenInUse.request();
if (status.isGranted || status.isLimited) {
emit(state.copyWith(step: LoginStep.phone));
return;
}
emit(state.copyWith(
permissionPermanentlyDenied: status.isPermanentlyDenied,
));
}
Future<void> openSystemSettings() => openAppSettings();
// ── الهاتف ─────────────────────────────────────────────────────────────
void phoneChanged(String value) {
emit(state.copyWith(
phone: value,
clearPhoneError: true,
clearFailure: true,
));
}
Future<void> submitPhone() async {
final error = validatePhone(state.phone);
if (error != null) {
emit(state.copyWith(phoneError: error));
return;
}
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
await _repo.sendOtp(state.phone.trim());
emit(state.copyWith(status: LoginStatus.idle, step: LoginStep.otp));
_startResendCountdown();
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
}
}
void editPhone() {
_resendTimer?.cancel();
emit(state.copyWith(
step: LoginStep.phone,
status: LoginStatus.idle,
resendIn: 0,
clearFailure: true,
));
}
// ── الرمز ──────────────────────────────────────────────────────────────
Future<void> resendOtp() async {
if (!state.canResend) return;
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
await _repo.sendOtp(state.phone.trim());
emit(state.copyWith(status: LoginStatus.idle));
_startResendCountdown();
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
// فوراً وعمّق الحظر.
_startResendCountdown();
}
}
Future<void> submitOtp(String code) async {
if (code.length != AppConfig.otpLength) return;
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
final user = await _repo.verifyOtp(phone: state.phone.trim(), code: code);
_resendTimer?.cancel();
emit(state.copyWith(
status: LoginStatus.success,
step: LoginStep.done,
user: user,
));
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
}
}
void _startResendCountdown() {
_resendTimer?.cancel();
emit(state.copyWith(resendIn: AppConfig.otpResendCooldown.inSeconds));
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final next = state.resendIn - 1;
if (next <= 0) timer.cancel();
emit(state.copyWith(resendIn: next < 0 ? 0 : next));
});
}
}
@@ -0,0 +1,87 @@
import 'package:equatable/equatable.dart';
import '../../../core/session/app_user.dart';
import 'auth_failure.dart';
import 'phone_error.dart';
/// خطوات الدخول بالترتيب الميداني المثبت في سيرو (docs/39 §2): بوابتان
/// كاملتان **قبل** أي حقل إدخال، ثم الهاتف فالرمز.
enum LoginStep { agreement, permission, phone, otp, done }
enum LoginStatus { idle, submitting, failure, success }
class LoginState extends Equatable {
const LoginState({
this.step = LoginStep.agreement,
this.status = LoginStatus.idle,
this.phone = '',
this.agreed = false,
this.failure,
this.phoneError,
this.resendIn = 0,
this.permissionPermanentlyDenied = false,
this.user,
});
final LoginStep step;
final LoginStatus status;
/// الرقم كما أدخله المستخدم — الخادم يطبّعه، فلا نطبّعه نحن (docs/38 §2).
final String phone;
final bool agreed;
final AuthFailure? failure;
final PhoneError? phoneError;
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
/// خمس دقائق، فالعدّاد ليس تجميلاً (docs/39 §2).
final int resendIn;
/// رُفض الإذن نهائياً — الحل الوحيد فتح إعدادات النظام.
final bool permissionPermanentlyDenied;
final AppUser? user;
bool get isSubmitting => status == LoginStatus.submitting;
bool get canResend => resendIn == 0 && !isSubmitting;
LoginState copyWith({
LoginStep? step,
LoginStatus? status,
String? phone,
bool? agreed,
AuthFailure? failure,
PhoneError? phoneError,
int? resendIn,
bool? permissionPermanentlyDenied,
AppUser? user,
bool clearFailure = false,
bool clearPhoneError = false,
}) {
return LoginState(
step: step ?? this.step,
status: status ?? this.status,
phone: phone ?? this.phone,
agreed: agreed ?? this.agreed,
failure: clearFailure ? null : (failure ?? this.failure),
phoneError: clearPhoneError ? null : (phoneError ?? this.phoneError),
resendIn: resendIn ?? this.resendIn,
permissionPermanentlyDenied:
permissionPermanentlyDenied ?? this.permissionPermanentlyDenied,
user: user ?? this.user,
);
}
@override
List<Object?> get props => [
step,
status,
phone,
agreed,
failure,
phoneError,
resendIn,
permissionPermanentlyDenied,
user,
];
}
@@ -0,0 +1,11 @@
/// أخطاء تحقّق **محليّة** لرقم الهاتف — تُكتشف قبل أي نداء شبكة.
/// الشروط الثلاثة منقولة من التحقّق المطبَّق ميدانياً في سيرو (docs/39 §2).
enum PhoneError { empty, leadingZero, tooShort }
PhoneError? validatePhone(String raw) {
final v = raw.trim();
if (v.isEmpty) return PhoneError.empty;
if (v.startsWith('0')) return PhoneError.leadingZero;
if (v.length < 9) return PhoneError.tooShort;
return null;
}
@@ -0,0 +1,56 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/ui/view_status.dart';
import '../data/auth_repository.dart';
import '../../../core/session/app_user.dart';
import 'auth_failure.dart';
class ProfileState {
const ProfileState({
this.status = ViewStatus.success,
this.saving = false,
this.failure,
this.user,
});
final ViewStatus status;
final bool saving;
final AuthFailure? failure;
final AppUser? user;
ProfileState copyWith({
ViewStatus? status,
bool? saving,
AuthFailure? failure,
AppUser? user,
bool clearFailure = false,
}) {
return ProfileState(
status: status ?? this.status,
saving: saving ?? this.saving,
failure: clearFailure ? null : (failure ?? this.failure),
user: user ?? this.user,
);
}
}
/// إكمال الملف بعد أول دخول. عقد الخادم يقبل `{ name, language }` فقط
/// (docs/38 §3) — لا اسم أول/أخير ولا بريد كما في سيرو (docs/39 §2).
class ProfileCubit extends Cubit<ProfileState> {
ProfileCubit(this._repo) : super(const ProfileState());
final AuthRepository _repo;
Future<AppUser?> save(String name) async {
emit(state.copyWith(saving: true, clearFailure: true));
try {
final user = await _repo.updateProfile(name: name.trim());
emit(state.copyWith(saving: false, user: user));
return user;
} on ApiException catch (e) {
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
return null;
}
}
}
@@ -0,0 +1,71 @@
import '../../../core/api/api_client.dart';
import '../../../core/storage/token_store.dart';
import '../../../core/session/app_user.dart';
import '../../../core/session/session_source.dart';
/// كل نداءات المصادقة (docs/38 §2). الـCubit لا ينادي Dio مباشرة أبداً
/// (docs/23 §3).
class AuthRepository implements SessionSource {
AuthRepository(this._api, this._tokens);
final ApiClient _api;
final TokenStore _tokens;
/// حدّ الخادم: ثلاثة طلبات كل خمس دقائق — الواجهة تمنع الضغط المتكرر
/// بعدّاد تنازلي (docs/39 §2).
Future<void> sendOtp(String phone) async {
await _api.post<Map<String, dynamic>>(
'/auth/send-otp',
body: {'phone': phone},
);
}
/// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها.
Future<AppUser> verifyOtp({
required String phone,
required String code,
String? referralCode,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/auth/verify-otp',
body: {
'phone': phone,
'code': code,
if (referralCode != null && referralCode.isNotEmpty)
'referral_code': referralCode,
},
);
await _tokens.save(
accessToken: res['access_token'] as String,
refreshToken: res['refresh_token'] as String,
// مفتاح توقيع الطلبات المالية (docs/38 §8) — يُخزَّن الآن ويُستهلك لاحقاً.
signingKey: res['signing_key'] as String?,
);
return AppUser.fromJson(res['user'] as Map<String, dynamic>);
}
@override
Future<AppUser> me() async {
final res = await _api.get<Map<String, dynamic>>('/users/me');
return AppUser.fromJson(res);
}
Future<AppUser> updateProfile({String? name, String? language}) async {
final res = await _api.patch<Map<String, dynamic>>(
'/users/me',
body: {
'name': ?name,
'language': ?language,
},
);
return AppUser.fromJson(res);
}
@override
Future<void> logout() => _tokens.clear();
@override
bool get hasSession => _tokens.isLoggedIn;
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../cubit/login_cubit.dart';
import '../cubit/login_state.dart';
import 'widgets/auth_header.dart';
/// البوابة الأولى: شاشة كاملة لا حوار، وزر متابعة **معطّل حتى يُؤشَّر**
/// (docs/39 §2). تُحفظ الموافقة فلا تتكرر.
class AgreementView extends StatelessWidget {
const AgreementView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocBuilder<LoginCubit, LoginState>(
buildWhen: (p, n) => p.agreed != n.agreed,
builder: (context, state) {
final cubit = context.read<LoginCubit>();
return TripzScaffold(
showAppBar: false,
bottomAction: TripzButton.primary(
label: l10n.agreementAccept,
onPressed: state.agreed ? cubit.acceptAgreement : null,
),
child: ListView(
children: [
const SizedBox(height: Space.xl),
AuthHeader(
icon: Icons.policy_outlined,
title: l10n.agreementTitle,
lead: l10n.agreementLead,
),
const SizedBox(height: Space.lg),
CheckboxListTile(
value: state.agreed,
onChanged: (v) => cubit.toggleAgreement(v ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(
l10n.agreementCheckbox,
style: context.texts.bodyMedium,
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/di.dart';
import '../../../core/session/session_cubit.dart';
import '../cubit/login_cubit.dart';
import '../cubit/login_state.dart';
import 'agreement_view.dart';
import 'otp_view.dart';
import 'permission_view.dart';
import 'phone_view.dart';
/// مضيف تدفّق الدخول: خطوة واحدة معروضة في كل لحظة، والانتقال بينها من
/// الـCubit لا من التنقّل — فلا يستطيع المستخدم الرجوع لخطوة تخطّاها.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> with WidgetsBindingObserver {
late final LoginCubit _cubit = sl<LoginCubit>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_cubit.close();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState lifecycle) {
// العودة من إعدادات النظام بعد منح الإذن يدوياً — بلا هذا الفحص تبقى
// شاشة الإذن عالقة (حالة حافة مثبتة ميدانياً، docs/39 §2).
if (lifecycle == AppLifecycleState.resumed &&
_cubit.state.step == LoginStep.permission) {
_cubit.checkLocationPermission();
}
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: BlocListener<LoginCubit, LoginState>(
listenWhen: (prev, next) => next.step == LoginStep.done,
listener: (context, state) {
final user = state.user;
if (user != null) context.read<SessionCubit>().onLoggedIn(user);
},
child: BlocBuilder<LoginCubit, LoginState>(
buildWhen: (prev, next) => prev.step != next.step,
builder: (context, state) => switch (state.step) {
LoginStep.agreement => const AgreementView(),
LoginStep.permission => const PermissionView(),
LoginStep.phone => const PhoneView(),
LoginStep.otp || LoginStep.done => const OtpView(),
},
),
),
);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_otp_field.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../cubit/login_cubit.dart';
import '../cubit/login_state.dart';
import 'widgets/auth_failure_text.dart';
import 'widgets/auth_header.dart';
class OtpView extends StatefulWidget {
const OtpView({super.key});
@override
State<OtpView> createState() => _OtpViewState();
}
class _OtpViewState extends State<OtpView> {
final _code = TextEditingController();
@override
void dispose() {
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final cubit = context.read<LoginCubit>();
return TripzScaffold(
showAppBar: false,
bottomAction: TripzButton.primary(
label: l10n.otpVerify,
loading: state.isSubmitting,
onPressed: () => cubit.submitOtp(_code.text),
),
child: ListView(
children: [
const SizedBox(height: Space.xl),
AuthHeader(
icon: Icons.mark_email_read_outlined,
title: l10n.otpTitle,
lead: l10n.otpLead(state.phone),
),
const SizedBox(height: Space.lg),
TripzOtpField(
controller: _code,
enabled: !state.isSubmitting,
hasError: state.failure != null,
onCompleted: cubit.submitOtp,
),
if (state.failure != null)
AuthFailureText(failure: state.failure!),
const SizedBox(height: Space.md),
// العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات
// المستخدمَ خمس دقائق (docs/38 §2).
Center(
child: state.canResend
? TripzButton.text(
label: l10n.otpResend,
onPressed: cubit.resendOtp,
)
: Text(
l10n.otpResendIn(state.resendIn),
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Center(
child: TripzButton.text(
label: l10n.otpChangeNumber,
onPressed: cubit.editPhone,
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/status_banner.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../cubit/login_cubit.dart';
import '../cubit/login_state.dart';
import 'widgets/auth_header.dart';
/// البوابة الثانية: إذن الموقع. عند الرفض النهائي لا يبقى إلا فتح إعدادات
/// النظام — والعودة منها يلتقطها `LoginPage` بمراقبة دورة الحياة.
class PermissionView extends StatelessWidget {
const PermissionView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocBuilder<LoginCubit, LoginState>(
buildWhen: (p, n) =>
p.permissionPermanentlyDenied != n.permissionPermanentlyDenied,
builder: (context, state) {
final cubit = context.read<LoginCubit>();
final denied = state.permissionPermanentlyDenied;
return TripzScaffold(
showAppBar: false,
bottomAction: TripzButton.primary(
label: denied ? l10n.actionOpenSettings : l10n.permissionAllow,
onPressed: denied
? cubit.openSystemSettings
: cubit.requestLocationPermission,
),
child: ListView(
children: [
const SizedBox(height: Space.xl),
AuthHeader(
icon: Icons.my_location_rounded,
title: l10n.permissionTitle,
lead: l10n.permissionLead,
),
if (denied) ...[
const SizedBox(height: Space.lg),
StatusBanner(
message: l10n.permissionDeniedForever,
tone: BannerTone.warning,
),
],
],
),
);
},
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_text_field.dart';
import '../cubit/login_cubit.dart';
import '../cubit/login_state.dart';
import 'widgets/auth_failure_text.dart';
import 'widgets/auth_header.dart';
class PhoneView extends StatelessWidget {
const PhoneView({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocBuilder<LoginCubit, LoginState>(
buildWhen: (p, n) =>
p.status != n.status ||
p.phoneError != n.phoneError ||
p.failure != n.failure,
builder: (context, state) {
final cubit = context.read<LoginCubit>();
return TripzScaffold(
showAppBar: false,
bottomAction: TripzButton.primary(
label: l10n.phoneSend,
loading: state.isSubmitting,
onPressed: cubit.submitPhone,
),
child: ListView(
children: [
const SizedBox(height: Space.xl),
AuthHeader(
icon: Icons.phone_outlined,
title: l10n.phoneTitle,
lead: l10n.phoneLead,
),
const SizedBox(height: Space.lg),
TripzTextField.phone(
label: l10n.phoneLabel,
hint: l10n.phoneHint,
enabled: !state.isSubmitting,
onChanged: cubit.phoneChanged,
onSubmitted: (_) => cubit.submitPhone(),
errorText: state.phoneError == null
? null
: AuthFailureText.phoneMessageOf(context, state.phoneError!),
),
if (state.failure != null)
AuthFailureText(failure: state.failure!),
],
),
);
},
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/di.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/ui/tripz_text_field.dart';
import '../cubit/profile_cubit.dart';
import '../../../core/session/session_cubit.dart';
import 'widgets/auth_failure_text.dart';
import 'widgets/auth_header.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
final _name = TextEditingController();
String? _error;
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocProvider(
create: (_) => ProfileCubit(sl()),
child: BlocBuilder<ProfileCubit, ProfileState>(
builder: (context, state) {
return TripzScaffold(
showAppBar: false,
bottomAction: TripzButton.primary(
label: l10n.actionSave,
loading: state.saving,
onPressed: () => _submit(context),
),
child: ListView(
children: [
const SizedBox(height: Space.xl),
AuthHeader(
icon: Icons.person_outline_rounded,
title: l10n.profileTitle,
lead: l10n.profileLead,
),
const SizedBox(height: Space.lg),
TripzTextField(
controller: _name,
label: l10n.profileNameLabel,
autofocus: true,
enabled: !state.saving,
errorText: _error,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.name],
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _submit(context),
),
if (state.failure != null)
AuthFailureText(failure: state.failure!),
],
),
);
},
),
);
}
Future<void> _submit(BuildContext context) async {
if (_name.text.trim().isEmpty) {
setState(() => _error = context.l10n.profileNameErrEmpty);
return;
}
final session = context.read<SessionCubit>();
final user = await context.read<ProfileCubit>().save(_name.text);
if (user != null) session.onProfileCompleted(user);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../cubit/auth_failure.dart';
import '../../cubit/phone_error.dart';
/// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ.
/// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`).
class AuthFailureText extends StatelessWidget {
const AuthFailureText({super.key, required this.failure});
final AuthFailure failure;
static String messageOf(BuildContext context, AuthFailure failure) {
final l10n = context.l10n;
return switch (failure) {
AuthFailure.invalidCode => l10n.otpErrInvalid,
AuthFailure.tooManyAttempts => l10n.otpErrTooManyAttempts,
AuthFailure.rateLimited => l10n.otpErrRateLimited,
AuthFailure.network => l10n.errorNetwork,
AuthFailure.timeout => l10n.errorTimeout,
AuthFailure.server => l10n.errorGeneric,
};
}
static String phoneMessageOf(BuildContext context, PhoneError error) {
final l10n = context.l10n;
return switch (error) {
PhoneError.empty => l10n.phoneErrEmpty,
PhoneError.leadingZero => l10n.phoneErrLeadingZero,
PhoneError.tooShort => l10n.phoneErrShort,
};
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Space.sm),
child: Row(
children: [
Icon(
Icons.error_outline_rounded,
size: Sizes.iconSm,
color: context.tripzColors.danger,
),
const SizedBox(width: Space.xs),
Expanded(
child: Text(
messageOf(context, failure),
style: context.texts.bodySmall?.copyWith(
color: context.tripzColors.danger,
),
),
),
],
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
/// ترويسة موحّدة لشاشات الدخول: أيقونة ثم عنوان ثم سطر شارح.
/// «الشاشة مهمة واحدة» — سؤال واحد وزر أساسي واحد أسفلها (docs/26 §8.1).
class AuthHeader extends StatelessWidget {
const AuthHeader({
super.key,
required this.icon,
required this.title,
required this.lead,
});
final IconData icon;
final String title;
final String lead;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(Space.sm),
decoration: BoxDecoration(
color: context.colors.primaryContainer,
borderRadius: Radii.card_,
),
child: Icon(
icon,
size: Sizes.icon,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(height: Space.md),
Text(title, style: context.texts.headlineSmall),
const SizedBox(height: Space.xs),
Text(
lead,
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_card.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../../core/session/session_cubit.dart';
import '../../../core/session/session_state.dart';
/// هيكل مؤقّت — تحلّ محلّه شاشة الخريطة وطلب الرحلة في م4 (docs/37).
/// الغرض الآن: إثبات أن سلسلة الدخول والجلسة والتوجيه تعمل طرفاً لطرف.
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SessionCubit, SessionState>(
builder: (context, session) {
final user = session.user;
return TripzScaffold(
title: context.l10n.appTitle,
child: ListView(
children: [
TripzCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user?.name ?? '',
style: context.texts.titleMedium,
),
const SizedBox(height: Space.xxs),
Text(
user?.phone ?? '',
textDirection: TextDirection.ltr,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: Space.lg),
TripzButton.secondary(
label: context.l10n.actionCancel,
expanded: true,
onPressed: context.read<SessionCubit>().logout,
),
],
),
);
},
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'settings_state.dart';
class SettingsCubit extends Cubit<SettingsState> {
SettingsCubit(this._prefs) : super(_read(_prefs));
final SharedPreferences _prefs;
static const _kTheme = 'settings.theme_mode';
static const _kLocale = 'settings.locale';
/// تُقرأ **متزامنة** في الباني: تأخير القراءة يعني ومضة بالثيم الخاطئ عند
/// كل إقلاع.
static SettingsState _read(SharedPreferences prefs) {
return SettingsState(
themeMode: switch (prefs.getString(_kTheme)) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
},
locale: Locale(prefs.getString(_kLocale) ?? 'ar'),
);
}
Future<void> setThemeMode(ThemeMode mode) async {
emit(state.copyWith(themeMode: mode));
await _prefs.setString(_kTheme, mode.name);
}
Future<void> setLocale(Locale locale) async {
emit(state.copyWith(locale: locale));
await _prefs.setString(_kLocale, locale.languageCode);
}
}
@@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
/// المظهر واللغة (docs/26 §4) — محفوظان محلياً، وليسا سرّاً فيذهبان إلى
/// `SharedPreferences` لا إلى التخزين الآمن.
class SettingsState extends Equatable {
const SettingsState({
this.themeMode = ThemeMode.system,
this.locale = const Locale('ar'),
});
final ThemeMode themeMode;
final Locale locale;
SettingsState copyWith({ThemeMode? themeMode, Locale? locale}) {
return SettingsState(
themeMode: themeMode ?? this.themeMode,
locale: locale ?? this.locale,
);
}
@override
List<Object?> get props => [themeMode, locale];
}
@@ -1,50 +0,0 @@
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),
),
],
),
),
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/build_config.dart';
import '../../../core/config.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/session/session_cubit.dart';
/// شاشة الإقلاع: تستعيد الجلسة ثم يتولّى الراوتر التوجيه. لا تنقّل من هنا —
/// قرار الوجهة في مكان واحد (`core/router.dart`).
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
// بعد أول إطار: `restore` قد يُصدر حالة فوراً، وإصدارها أثناء البناء خطأ.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<SessionCubit>().restore();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(BuildConfig.logoAsset, width: 140, height: 140),
const SizedBox(height: Space.lg),
Text(
BuildConfig.appName,
style: context.texts.headlineMedium?.copyWith(
color: context.colors.primary,
),
),
const SizedBox(height: Space.xs),
Text(
AppConfig.appRole == 'driver'
? context.l10n.splashDriver
: context.l10n.splashRider,
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Space.xl),
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
],
),
),
);
}
}
+8 -3
View File
@@ -1,10 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'app/app.dart'; import 'app.dart';
import 'core/di/injector.dart'; import 'core/di.dart';
import 'core/session/session_cubit.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await setupInjector(); await setupInjector();
runApp(const TripzApp()); runApp(
// الجلسة فوق الجميع: الراوتر والشاشات يقرآنها من نقطة واحدة.
BlocProvider.value(value: sl<SessionCubit>(), child: const TripzApp()),
);
} }
+76 -12
View File
@@ -81,6 +81,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
url: "https://pub.dev"
source: hosted
version: "12.4.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -121,14 +137,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -207,10 +215,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.2" version: "4.1.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -437,6 +445,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
url: "https://pub.dev"
source: hosted
version: "12.0.3"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: "11b7e94a9d2fbee23c27f0cae0105c6266c03fd83b9a2eda6cf09141fc82624b"
url: "https://pub.dev"
source: hosted
version: "9.5.0"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
url: "https://pub.dev"
source: hosted
version: "0.1.4+1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23
url: "https://pub.dev"
source: hosted
version: "4.4.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
url: "https://pub.dev"
source: hosted
version: "0.2.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -622,10 +678,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+8 -1
View File
@@ -29,10 +29,14 @@ dependencies:
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
shared_preferences: ^2.3.3 shared_preferences: ^2.3.3
# م3: الأذونات وبصمة الجهاز
permission_handler: ^12.0.1
device_info_plus: ^12.3.0
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7): # باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
# م3: device_info_plus · crypto (توقيع الطلبات وبصمة الجهاز) # م3 لاحقاً: crypto (توقيع طلبات النقاط المالية)
# م4: الخريطة · geolocator · socket_io_client # م4: الخريطة · geolocator · socket_io_client
# م5: التقييم والمحفظة م6: الإشعارات والدردشة # م5: التقييم والمحفظة م6: الإشعارات والدردشة
@@ -44,6 +48,9 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
# توليد الترجمة من ARB (docs/26 §4)
generate: true
assets: assets:
- assets/ - assets/
- assets/images/ - assets/images/
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# مُولَّد من ARB — لا يُحرَّر
lib/core/l10n/gen/
+7
View File
@@ -0,0 +1,7 @@
# توليد الترجمة القياسي (docs/26 §4): ARB + gen_l10n. لا نص مرئي داخل widget.
arb-dir: lib/core/l10n
template-arb-file: app_ar.arb
output-localization-file: app_localizations.dart
output-class: L10n
output-dir: lib/core/l10n/gen
nullable-getter: false
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'core/build_config.dart';
import 'core/design/theme.dart';
import 'core/di.dart';
import 'core/l10n/l10n.dart';
import 'core/router.dart';
import 'features/settings/cubit/settings_cubit.dart';
import 'features/settings/cubit/settings_state.dart';
class TripzApp extends StatelessWidget {
const TripzApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<SettingsCubit>(),
child: BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, settings) {
return MaterialApp.router(
title: BuildConfig.appName,
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
// الثيم يُبنى من مدخلين لا ثالث لهما (docs/26 §4).
theme: buildTheme(Brightness.light, settings.locale),
darkTheme: buildTheme(Brightness.dark, settings.locale),
themeMode: settings.themeMode,
// الاتجاه يتبع اللغة آلياً — لا `Directionality` مفروضة فوق كل
// شيء، وإلا انكسرت الإنجليزية (docs/26 §4).
locale: settings.locale,
supportedLocales: L10n.supportedLocales,
localizationsDelegates: const [
...L10n.localizationsDelegates,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// سقف تكبير النص 1.3 — وهو ما تُفحص عليه الشاشات (docs/26 §9).
// ما فوقه يكسر التخطيط بلا مكسب قرائي.
builder: (context, child) => MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.3,
child: child ?? const SizedBox.shrink(),
),
);
},
),
);
}
}
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,19 +0,0 @@
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(),
),
],
);
@@ -1,6 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/app_config.dart'; import '../config.dart';
import '../storage/token_store.dart'; import '../storage/token_store.dart';
import 'api_exception.dart'; import 'api_exception.dart';
import 'auth_interceptor.dart'; import 'auth_interceptor.dart';
@@ -2,7 +2,8 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../config/app_config.dart'; import '../build_config.dart';
import '../config.dart';
import '../storage/token_store.dart'; import '../storage/token_store.dart';
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه. /// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
@@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor {
RequestOptions options, RequestOptions options,
RequestInterceptorHandler handler, RequestInterceptorHandler handler,
) async { ) async {
options.headers['x-tenant-id'] = AppConfig.tenantSlug; options.headers['x-tenant-id'] = BuildConfig.tenantSlug;
options.headers['x-app-role'] = AppConfig.appRole; options.headers['x-app-role'] = AppConfig.appRole;
final deviceId = await _tokens.deviceId(); final deviceId = await _tokens.deviceId();
@@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor {
'/auth/refresh', '/auth/refresh',
data: {'refresh_token': refresh}, data: {'refresh_token': refresh},
options: Options(headers: { options: Options(headers: {
'x-tenant-id': AppConfig.tenantSlug, 'x-tenant-id': BuildConfig.tenantSlug,
'x-app-role': AppConfig.appRole, 'x-app-role': AppConfig.appRole,
'x-device-id': ?deviceId, 'x-device-id': ?deviceId,
}), }),
+40
View File
@@ -0,0 +1,40 @@
// ⚠️ ملف مُولَّد (docs/23 §1 — N3). لا يُحرَّر يدوياً؛ يُعاد توليده لكل
// مستأجر/طبقة عند البناء. القيم هنا هي القيم الافتراضية للمستأجر التجريبي.
import 'package:flutter/material.dart';
/// المتغيّر الوحيد بين المستأجرين: **لون · لوغو · اسم** (docs/26 §0).
/// أي شيء آخر يتغيّر بين مستأجرين = خطأ يُصحَّح.
class BuildConfig {
const BuildConfig._();
static const String appName = 'Tripz';
static const String tenantSlug = 'siro';
/// اللون البذرة — منه تُشتقّ `ColorScheme` للوضعين.
static const Color seedColor = Color(0xFF1F6FEB);
static const String logoAsset = 'assets/images/logo.png';
static const String splashLogoAsset = 'assets/images/splash_logo.png';
}
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
/// من الـbinary عند إطفائها — خريطة في وقت التشغيل تُبقي الكود قابلاً
/// للاستخراج.
///
/// هذه طبقة **الطبقة المُباعة** (lite/pro/max). طبقة ثانية مستقلة تأتي من
/// `GET /tenant/config/:slug` وقت التشغيل (docs/38 §10) وتخفي ما اشتراه
/// المستأجر فعلاً — الاثنتان تتقاطعان: العَلَم `const` يقرّر ما يُبنى، وردّ
/// الخادم يقرّر ما يُعرض ممّا بُني.
class Features {
const Features._();
static const bool wallet = true;
static const bool chat = true;
static const bool calls = true;
static const bool rideTypes = true;
static const bool coupons = true;
static const bool geofence = false;
static const bool transit = false;
static const bool marketIntel = false;
}
@@ -1,11 +1,11 @@
/// إعداد البناء — كل قيمة هنا `const` تُمرَّر عبر `--dart-define` عند البناء. /// إعداد وقت التشغيل — يُمرَّر بـ`--dart-define` عند البناء (docs/23 §1).
/// ///
/// هذا هو أساس نموذج lite/pro/max (docs/37 §6.25): بناء مولَّد بأعلام ثابتة، /// ما يخصّ **هوية المستأجر البصرية** (لون · لوغو · اسم) يعيش في
/// لا فروع كود. القيم الافتراضية تشير للسيرفر التجريبي. /// `build_config.dart` المُولَّد، لا هنا.
class AppConfig { class AppConfig {
const AppConfig._(); const AppConfig._();
/// أصل الـAPI **مع** `/api` — كما في docs/38. /// أصل الـAPI **مع** `/api` (docs/38).
static const String apiBaseUrl = String.fromEnvironment( static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://tripz-api.intaleqapp.com/api', defaultValue: 'https://tripz-api.intaleqapp.com/api',
@@ -17,18 +17,19 @@ class AppConfig {
defaultValue: 'https://tripz-api.intaleqapp.com', 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). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.** /// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
static const String appRole = 'rider'; static const String appRole = 'rider';
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار /// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
/// حتى 401 يعني فشل طلب في منتصف رحلة. static const int otpLength = 4;
/// مهلة إعادة إرسال الرمز. الخادم يحدّ `send-otp` بثلاثة طلبات كل خمس
/// دقائق، فبلا عدّاد تنازلي يُحظر المستخدم بثلاث ضغطات (docs/39 §2).
static const Duration otpResendCooldown = Duration(seconds: 60);
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)،
/// فالانتظار حتى 401 يعني فشل طلب في منتصف رحلة.
static const Duration tokenRefreshLeeway = Duration(seconds: 60); static const Duration tokenRefreshLeeway = Duration(seconds: 60);
static const Duration connectTimeout = Duration(seconds: 20); static const Duration connectTimeout = Duration(seconds: 20);
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../build_config.dart';
import 'tokens.dart';
import 'tripz_colors.dart';
import 'typography.dart';
/// دالة بناء الثيم الوحيدة (docs/26 §4): مدخلان لا ثالث لهما.
///
/// الاتجاه (RTL/LTR) **لا يُفرض هنا** — `MaterialApp` يشتقّه من الـlocale
/// وحده. فرض `Directionality.rtl` يكسر الإنجليزية (docs/26 §4).
ThemeData buildTheme(Brightness brightness, Locale locale) {
final scheme = ColorScheme.fromSeed(
seedColor: BuildConfig.seedColor,
brightness: brightness,
);
final isDark = brightness == Brightness.dark;
final texts = buildTextTheme(brightness, locale);
final tripz = isDark ? TripzColors.dark : TripzColors.light;
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
brightness: brightness,
fontFamily: AppFonts.familyFor(locale),
textTheme: texts,
scaffoldBackgroundColor: scheme.surface,
extensions: [tripz],
// AppBar مسطّح بلا ظل (docs/26 §6).
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: texts.titleLarge?.copyWith(color: scheme.onSurface),
),
cardTheme: CardThemeData(
color: tripz.surfaceRaised,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: Radii.card_,
side: BorderSide(color: scheme.outlineVariant),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
showDragHandle: true,
shape: const RoundedRectangleBorder(borderRadius: Radii.sheetTop),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark ? scheme.surfaceContainerHigh : scheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
),
border: _fieldBorder(scheme.outlineVariant),
enabledBorder: _fieldBorder(scheme.outlineVariant),
focusedBorder: _fieldBorder(scheme.primary, width: 1.5),
errorBorder: _fieldBorder(scheme.error),
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
errorStyle: texts.bodySmall?.copyWith(color: scheme.error),
),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
space: 1,
thickness: 1,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: scheme.inverseSurface,
contentTextStyle:
texts.bodyMedium?.copyWith(color: scheme.onInverseSurface),
shape: const RoundedRectangleBorder(borderRadius: Radii.card_),
),
);
}
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) {
return OutlineInputBorder(
borderRadius: Radii.field_,
borderSide: BorderSide(color: color, width: width),
);
}
@@ -0,0 +1,69 @@
import 'package:flutter/widgets.dart';
/// الرموز الثابتة (docs/26 §3). **الشاشة لا تخترع رقماً** — لا مسافة ولا زاوية
/// ولا مدّة خارج هذا الملف.
class Space {
const Space._();
/// سلّم مضاعفات الأربعة.
static const double xxs = 4;
static const double xs = 8;
static const double sm = 12;
/// حشوة الصفحة الافتراضية.
static const double md = 16;
static const double lg = 24;
static const double xl = 32;
static const double xxl = 48;
static const EdgeInsets page = EdgeInsets.all(md);
static const EdgeInsets pageH = EdgeInsets.symmetric(horizontal: md);
}
class Radii {
const Radii._();
/// حقول وشارات.
static const double field = 8;
/// أزرار وبطاقات.
static const double card = 12;
/// الأوراق السفلية.
static const double sheet = 24;
static const BorderRadius field_ = BorderRadius.all(Radius.circular(field));
static const BorderRadius card_ = BorderRadius.all(Radius.circular(card));
static const BorderRadius sheetTop = BorderRadius.vertical(
top: Radius.circular(sheet),
);
}
class Motion {
const Motion._();
/// استجابة لمسة.
static const Duration tap = Duration(milliseconds: 120);
/// انتقال ضمن الشاشة.
static const Duration inScreen = Duration(milliseconds: 240);
/// ورقة سفلية أو صفحة.
static const Duration page = Duration(milliseconds: 400);
static const Curve curve = Curves.easeOutCubic;
}
class Sizes {
const Sizes._();
static const double iconSm = 20;
static const double icon = 24;
static const double iconLg = 32;
/// ارتفاع زر الـCTA وحقل الإدخال.
static const double control = 52;
/// أدنى هدف لمس مقبول.
static const double touchTarget = 48;
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2).
///
/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها،
/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر
/// كل المستأجرين كما يفرض عقد §0.
@immutable
class TripzColors extends ThemeExtension<TripzColors> {
const TripzColors({
required this.success,
required this.onSuccess,
required this.warning,
required this.onWarning,
required this.danger,
required this.onDanger,
required this.info,
required this.surfaceRaised,
required this.mapRoute,
required this.mapOrigin,
required this.mapDestination,
required this.skeleton,
});
final Color success;
final Color onSuccess;
final Color warning;
final Color onWarning;
final Color danger;
final Color onDanger;
final Color info;
/// السطح المرتفع — في الليلي ارتفاعٌ **بالسطوع لا بالظل** (docs/26 §2).
final Color surfaceRaised;
final Color mapRoute;
final Color mapOrigin;
final Color mapDestination;
/// لون هيكل التحميل (skeleton) — لا spinner فارغ (docs/26 §8.4).
final Color skeleton;
static const light = TripzColors(
success: Color(0xFF1B8A5A),
onSuccess: Color(0xFFFFFFFF),
warning: Color(0xFFB4690E),
onWarning: Color(0xFFFFFFFF),
danger: Color(0xFFC5303B),
onDanger: Color(0xFFFFFFFF),
info: Color(0xFF2563A8),
surfaceRaised: Color(0xFFFFFFFF),
mapRoute: Color(0xFF1F6FEB),
mapOrigin: Color(0xFF1B8A5A),
mapDestination: Color(0xFFC5303B),
skeleton: Color(0xFFE6E8EC),
);
static const dark = TripzColors(
success: Color(0xFF4ECB8D),
onSuccess: Color(0xFF00281A),
warning: Color(0xFFE9A63B),
onWarning: Color(0xFF2A1A00),
danger: Color(0xFFF2707A),
onDanger: Color(0xFF33000A),
info: Color(0xFF7FB2F0),
// ليس أسود صرفاً — رمادي داكن متدرّج.
surfaceRaised: Color(0xFF20232A),
mapRoute: Color(0xFF5B9BFF),
mapOrigin: Color(0xFF4ECB8D),
mapDestination: Color(0xFFF2707A),
skeleton: Color(0xFF2C3038),
);
@override
TripzColors copyWith({
Color? success,
Color? onSuccess,
Color? warning,
Color? onWarning,
Color? danger,
Color? onDanger,
Color? info,
Color? surfaceRaised,
Color? mapRoute,
Color? mapOrigin,
Color? mapDestination,
Color? skeleton,
}) {
return TripzColors(
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
warning: warning ?? this.warning,
onWarning: onWarning ?? this.onWarning,
danger: danger ?? this.danger,
onDanger: onDanger ?? this.onDanger,
info: info ?? this.info,
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
mapRoute: mapRoute ?? this.mapRoute,
mapOrigin: mapOrigin ?? this.mapOrigin,
mapDestination: mapDestination ?? this.mapDestination,
skeleton: skeleton ?? this.skeleton,
);
}
@override
TripzColors lerp(ThemeExtension<TripzColors>? other, double t) {
if (other is! TripzColors) return this;
return TripzColors(
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
warning: Color.lerp(warning, other.warning, t)!,
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
danger: Color.lerp(danger, other.danger, t)!,
onDanger: Color.lerp(onDanger, other.onDanger, t)!,
info: Color.lerp(info, other.info, t)!,
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
mapRoute: Color.lerp(mapRoute, other.mapRoute, t)!,
mapOrigin: Color.lerp(mapOrigin, other.mapOrigin, t)!,
mapDestination: Color.lerp(mapDestination, other.mapDestination, t)!,
skeleton: Color.lerp(skeleton, other.skeleton, t)!,
);
}
}
extension TripzColorsX on BuildContext {
TripzColors get tripzColors => Theme.of(this).extension<TripzColors>()!;
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get texts => Theme.of(this).textTheme;
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// الخطوط (docs/26 §1) — ملفات محلية في `assets/fonts/`. حزمة `google_fonts`
/// ممنوعة: الشكل يجب أن يكون حتمياً من أول إقلاع بلا شبكة.
class AppFonts {
const AppFonts._();
static const arabic = 'IBMPlexSansArabic';
static const latin = 'Inter';
/// الخط يتبع اللغة، والآخر احتياط — كي لا تنكسر كلمة عربية داخل جملة
/// إنجليزية ولا العكس.
static String familyFor(Locale locale) =>
locale.languageCode == 'ar' ? arabic : latin;
static List<String> fallbackFor(Locale locale) =>
locale.languageCode == 'ar' ? const [latin] : const [arabic];
}
/// أرقام tabular: تمنع «رقص» الأعمدة حين تتغيّر القيمة — للأسعار والعدّادات
/// والمسافات الحيّة (docs/26 §1). يُغني عن خط `digit` المتقاعد.
const tabularFigures = [FontFeature.tabularFigures()];
TextTheme buildTextTheme(Brightness brightness, Locale locale) {
final base = brightness == Brightness.dark
? Typography.material2021().white
: Typography.material2021().black;
final family = AppFonts.familyFor(locale);
final fallback = AppFonts.fallbackFor(locale);
return base
.apply(fontFamily: family, fontFamilyFallback: fallback)
.copyWith(
displaySmall: base.displaySmall?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w700,
),
headlineMedium: base.headlineMedium?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w700,
),
headlineSmall: base.headlineSmall?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
titleLarge: base.titleLarge?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
titleMedium: base.titleMedium?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
labelLarge: base.labelLarge?.copyWith(
fontFamily: family,
fontFamilyFallback: fallback,
fontWeight: FontWeight.w600,
),
);
}
/// نمط الأرقام الحيّة — يُستعمل للأجرة والرصيد والعدّاد التنازلي.
/// دائماً Inter بأرقام tabular، مهما كانت لغة الواجهة (docs/26 §5: الأرقام
/// لاتينية في اللغتين).
TextStyle numericStyle(TextStyle? base) => (base ?? const TextStyle()).copyWith(
fontFamily: AppFonts.latin,
fontFeatures: tabularFigures,
);
+66
View File
@@ -0,0 +1,66 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../features/auth/cubit/login_cubit.dart';
import '../features/auth/data/auth_repository.dart';
import '../features/settings/cubit/settings_cubit.dart';
import 'api/api_client.dart';
import 'session/session_cubit.dart';
import 'storage/token_store.dart';
final sl = GetIt.instance;
/// نقطة الإقفال الوحيدة (docs/23 §5): الميزة المطفأة **لا تُسجَّل**، بدل
/// `if` مبعثرة في الشاشات.
Future<void> setupInjector() async {
final prefs = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(prefs);
const secure = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
final tokens = TokenStore(secure);
await tokens.load();
await _ensureDeviceId(tokens);
sl.registerSingleton<TokenStore>(tokens);
sl.registerSingleton<SettingsCubit>(SettingsCubit(prefs));
// انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر
// يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor.
sl.registerSingleton<ApiClient>(
ApiClient.create(
tokens: tokens,
onSessionExpired: () async => sl<SessionCubit>().onSessionExpired(),
),
);
sl.registerSingleton<AuthRepository>(AuthRepository(sl(), sl()));
sl.registerSingleton<SessionCubit>(SessionCubit(sl()));
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
}
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل
/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**.
Future<void> _ensureDeviceId(TokenStore tokens) async {
if (await tokens.deviceId() != null) return;
final info = DeviceInfoPlugin();
String? id;
try {
if (defaultTargetPlatform == TargetPlatform.android) {
id = (await info.androidInfo).id;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
id = (await info.iosInfo).identifierForVendor;
}
} catch (_) {
// منصّة لا تجيب — المعرّف الفارغ أفضل من إقلاع فاشل.
}
if (id != null && id.isNotEmpty) await tokens.setDeviceId(id);
}
-31
View File
@@ -1,31 +0,0 @@
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 {},
),
);
}
+67
View File
@@ -0,0 +1,67 @@
{
"@@locale": "ar",
"appTitle": "Tripz",
"actionRetry": "أعد المحاولة",
"actionCancel": "إلغاء",
"actionContinue": "متابعة",
"actionSave": "حفظ",
"actionOpenSettings": "افتح الإعدادات",
"errorGeneric": "حدث خطأ غير متوقّع",
"errorNetwork": "تعذّر الاتصال بالخادم",
"errorTimeout": "انتهت مهلة الاتصال",
"emptyDefault": "لا يوجد شيء هنا بعد",
"splashRider": "تطبيق الراكب",
"splashDriver": "تطبيق السائق",
"agreementTitle": "شروط الاستخدام",
"agreementLead": "قبل أن نبدأ، اقرأ الشروط ووافق عليها.",
"agreementCheckbox": "قرأت الشروط وأوافق عليها",
"agreementAccept": "أوافق وأتابع",
"permissionTitle": "نحتاج موقعك",
"permissionLead": "الموقع هو ما يجعل الرحلة ممكنة: به نعرف أين أنت وأين السائق.",
"permissionAllow": "السماح بالوصول للموقع",
"permissionDeniedForever": "رفضتَ الإذن نهائياً. افتح الإعدادات وفعّله يدوياً ثم عُد.",
"phoneTitle": "أهلاً بك في Tripz",
"phoneLead": "أدخل رقم هاتفك، ونرسل لك رمز تحقّق.",
"phoneLabel": "رقم الهاتف",
"phoneHint": "بلا الصفر في البداية",
"phoneSend": "أرسل رمز التحقّق",
"phoneErrEmpty": "أدخل رقم هاتفك",
"phoneErrLeadingZero": "أدخل الرقم بلا الصفر في البداية",
"phoneErrShort": "الرقم قصير جداً",
"otpTitle": "أدخل رمز التحقّق",
"otpLead": "أرسلنا رمزاً من أربع خانات إلى {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"otpVerify": "تحقّق وتابع",
"otpResend": "إعادة إرسال الرمز",
"otpResendIn": "إعادة الإرسال بعد {seconds} ثانية",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"otpErrIncomplete": "أدخل الرمز كاملاً",
"otpErrInvalid": "الرمز غير صحيح أو انتهت صلاحيته",
"otpErrTooManyAttempts": "محاولات كثيرة. اطلب رمزاً جديداً.",
"otpErrRateLimited": "طلبتَ الرمز مرات كثيرة. انتظر قليلاً ثم أعد المحاولة.",
"otpChangeNumber": "تعديل الرقم",
"profileTitle": "أكمل ملفك",
"profileLead": "اسمك يظهر للسائق عند الرحلة.",
"profileNameLabel": "الاسم",
"profileNameErrEmpty": "أدخل اسمك",
"sessionExpired": "انتهت جلستك. سجّل الدخول من جديد.",
"settingsTitle": "الإعدادات",
"settingsTheme": "المظهر",
"settingsThemeSystem": "حسب النظام",
"settingsThemeLight": "فاتح",
"settingsThemeDark": "داكن",
"settingsLanguage": "اللغة",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
+67
View File
@@ -0,0 +1,67 @@
{
"@@locale": "en",
"appTitle": "Tripz",
"actionRetry": "Try again",
"actionCancel": "Cancel",
"actionContinue": "Continue",
"actionSave": "Save",
"actionOpenSettings": "Open settings",
"errorGeneric": "Something went wrong",
"errorNetwork": "Could not reach the server",
"errorTimeout": "The connection timed out",
"emptyDefault": "Nothing here yet",
"splashRider": "Rider app",
"splashDriver": "Driver app",
"agreementTitle": "Terms of use",
"agreementLead": "Before we start, read the terms and accept them.",
"agreementCheckbox": "I have read and accept the terms",
"agreementAccept": "Accept and continue",
"permissionTitle": "We need your location",
"permissionLead": "Location is what makes a trip possible: it tells us where you are and where your driver is.",
"permissionAllow": "Allow location access",
"permissionDeniedForever": "You denied the permission permanently. Open settings, enable it, then come back.",
"phoneTitle": "Welcome to Tripz",
"phoneLead": "Enter your phone number and we'll send you a verification code.",
"phoneLabel": "Phone number",
"phoneHint": "without the leading zero",
"phoneSend": "Send verification code",
"phoneErrEmpty": "Enter your phone number",
"phoneErrLeadingZero": "Enter the number without the leading zero",
"phoneErrShort": "That number is too short",
"otpTitle": "Enter your code",
"otpLead": "We sent a 4-digit code to {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"otpVerify": "Verify and continue",
"otpResend": "Resend the code",
"otpResendIn": "Resend in {seconds}s",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"otpErrIncomplete": "Enter the full code",
"otpErrInvalid": "That code is wrong or expired",
"otpErrTooManyAttempts": "Too many attempts. Request a new code.",
"otpErrRateLimited": "You requested the code too many times. Wait a moment and try again.",
"otpChangeNumber": "Change number",
"profileTitle": "Complete your profile",
"profileLead": "Your name is shown to the driver during a trip.",
"profileNameLabel": "Name",
"profileNameErrEmpty": "Enter your name",
"sessionExpired": "Your session ended. Please sign in again.",
"settingsTitle": "Settings",
"settingsTheme": "Appearance",
"settingsThemeSystem": "Follow system",
"settingsThemeLight": "Light",
"settingsThemeDark": "Dark",
"settingsLanguage": "Language",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
import 'gen/app_localizations.dart';
export 'gen/app_localizations.dart';
/// كل نص مرئي يمرّ من هنا: `context.l10n.phoneTitle` (docs/26 §4).
extension L10nX on BuildContext {
L10n get l10n => L10n.of(this);
}
+78
View File
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'session/session_cubit.dart';
import 'session/session_state.dart';
import '../features/auth/view/login_page.dart';
import '../features/auth/view/profile_page.dart';
import '../features/home/view/home_page.dart';
import '../features/splash/view/splash_page.dart';
import 'di.dart';
class Routes {
const Routes._();
static const splash = '/';
static const login = '/login';
static const profile = '/profile';
static const home = '/home';
}
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
/// موزّعة في الشاشات، فلا تتناقض حالتان.
final appRouter = GoRouter(
initialLocation: Routes.splash,
refreshListenable: _CubitListenable(sl<SessionCubit>().stream),
redirect: (context, state) {
final session = sl<SessionCubit>().state;
final loc = state.matchedLocation;
// قبل أن نعرف: نبقى على الإقلاع ولا نقرّر شيئاً.
if (!session.isReady) return loc == Routes.splash ? null : Routes.splash;
return switch (session.status) {
SessionStatus.unauthenticated =>
loc == Routes.login ? null : Routes.login,
SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile,
SessionStatus.authenticated =>
(loc == Routes.login || loc == Routes.profile || loc == Routes.splash)
? Routes.home
: null,
SessionStatus.unknown => Routes.splash,
};
},
routes: [
GoRoute(
path: Routes.splash,
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: Routes.login,
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: Routes.profile,
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const HomePage(),
),
],
);
/// جسر بين تيّار الـCubit و`refreshListenable` الذي يطلبه go_router.
class _CubitListenable extends ChangeNotifier {
_CubitListenable(Stream<dynamic> stream) {
_sub = stream.listen((_) => notifyListeners());
}
late final StreamSubscription<dynamic> _sub;
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
@@ -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];
}
@@ -1,26 +0,0 @@
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,54 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الفراغ: رسالة **تدلّ على الفعل التالي** لا مجرّد «لا يوجد»
/// (docs/26 §8.4).
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = Icons.inbox_rounded,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: Sizes.iconLg,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.emptyDefault,
textAlign: TextAlign.center,
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(label: actionLabel!, onPressed: onAction),
],
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الخطأ: رسالة **بالعربية** مع «أعد المحاولة» — لا شاشة صامتة
/// (docs/26 §8.4). الرسالة تأتي جاهزة من الـCubit (docs/23 §3).
class ErrorView extends StatelessWidget {
const ErrorView({super.key, this.message, this.onRetry});
final String? message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: Sizes.iconLg,
color: context.tripzColors.danger,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.errorGeneric,
textAlign: TextAlign.center,
style: context.texts.bodyMedium,
),
if (onRetry != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(
label: context.l10n.actionRetry,
onPressed: onRetry,
),
],
],
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
/// هيكل تحميل — **لا spinner فارغ** (docs/26 §8.4).
///
/// النبض `AnimatedBuilder` على `Opacity` وحدها: لا يُعاد بناء الشجرة تحته،
/// وهذا ما يفرق بين skeleton رخيص وآخر يأكل إطارات.
class Skeleton extends StatefulWidget {
const Skeleton({
super.key,
this.width,
this.height = 16,
this.radius = Radii.field,
});
/// ثلاثة أسطر بأطوال متفاوتة — الشكل الافتراضي لأي قائمة تُحمَّل.
const Skeleton.lines({super.key})
: width = null,
height = -1,
radius = Radii.field;
final double? width;
final double height;
final double radius;
@override
State<Skeleton> createState() => _SkeletonState();
}
class _SkeletonState extends State<Skeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = context.tripzColors.skeleton;
final child = widget.height < 0
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bar(color, double.infinity),
const SizedBox(height: Space.sm),
_bar(color, 220),
const SizedBox(height: Space.sm),
_bar(color, 140),
],
)
: _bar(color, widget.width ?? double.infinity, widget.height);
// تُحترم إعدادات تقليل الحركة في النظام (docs/26 §8.5).
if (MediaQuery.disableAnimationsOf(context)) return child;
return AnimatedBuilder(
animation: _controller,
builder: (_, inner) => Opacity(
opacity: 0.45 + (_controller.value * 0.35),
child: inner,
),
child: child,
);
}
Widget _bar(Color color, double width, [double height = 16]) => Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(widget.radius),
),
);
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
enum BannerTone { info, success, warning, danger }
/// شريط الحالة الحيّ (docs/26 §7) — حالة الرحلة، تحذير الرصيد، انقطاع الشبكة.
/// اللون من الأدوار الدلالية لا من hex.
class StatusBanner extends StatelessWidget {
const StatusBanner({
super.key,
required this.message,
this.tone = BannerTone.info,
this.icon,
this.trailing,
});
final String message;
final BannerTone tone;
final IconData? icon;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final c = context.tripzColors;
final color = switch (tone) {
BannerTone.info => c.info,
BannerTone.success => c.success,
BannerTone.warning => c.warning,
BannerTone.danger => c.danger,
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.sm,
vertical: Space.xs,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: Radii.field_,
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
children: [
Icon(icon ?? Icons.info_outline_rounded, size: Sizes.iconSm, color: color),
const SizedBox(width: Space.xs),
Expanded(
child: Text(message, style: context.texts.bodySmall),
),
?trailing,
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
enum _ButtonKind { primary, secondary, text }
/// الزر الموحّد (docs/26 §7). ارتفاع 52، وحالة تحميل **داخلية**: الشاشة
/// تمرّر `loading: true` ولا تستبدل الزر بمؤشّر — فلا يقفز التخطيط.
class TripzButton extends StatelessWidget {
const TripzButton.primary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = true,
}) : _kind = _ButtonKind.primary;
const TripzButton.secondary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.secondary;
const TripzButton.text({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.text;
final String label;
final VoidCallback? onPressed;
final bool loading;
final IconData? icon;
final bool expanded;
final _ButtonKind _kind;
@override
Widget build(BuildContext context) {
// أثناء التحميل يُعطَّل الزر — يمنع الإرسال المزدوج بلا حارس في كل شاشة.
final onTap = loading ? null : onPressed;
final child = _Content(label: label, icon: icon, loading: loading);
final button = switch (_kind) {
_ButtonKind.primary => FilledButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.secondary => OutlinedButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.text => TextButton(
onPressed: onTap,
style: _style(context),
child: child,
),
};
if (!expanded) return button;
return SizedBox(width: double.infinity, child: button);
}
ButtonStyle _style(BuildContext context) => ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size(0, Sizes.control),
),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: Radii.card_),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: Space.lg),
),
textStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.labelLarge,
),
);
}
class _Content extends StatelessWidget {
const _Content({required this.label, this.icon, required this.loading});
final String label;
final IconData? icon;
final bool loading;
@override
Widget build(BuildContext context) {
if (loading) {
return const SizedBox(
width: Sizes.iconSm,
height: Sizes.iconSm,
child: CircularProgressIndicator(strokeWidth: 2.2),
);
}
if (icon == null) return Text(label);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Sizes.iconSm),
const SizedBox(width: Space.xs),
Text(label),
],
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// بطاقة موحّدة (docs/26 §7): الرحلة · السائق · المحفظة.
/// الحدّ لا الظل — «الوضوح قبل الزينة» (docs/26 §8.2).
class TripzCard extends StatelessWidget {
const TripzCard({
super.key,
required this.child,
this.onTap,
this.padding = Space.page,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Card(
clipBehavior: Clip.antiAlias,
child: onTap == null
? content
: InkWell(onTap: onTap, child: content),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل رمز التحقّق — أربع خانات (طول رمز تريبز، docs/38 §2).
///
/// حقل حقيقي واحد تحت خانات معروضة: يُبقي اللصق والإكمال التلقائي من الرسالة
/// (`AutofillHints.oneTimeCode`) يعملان — وهو ما يكسره تقسيمُه إلى أربعة
/// حقول منفصلة. يُستدعى [onCompleted] عند اكتمال الطول.
class TripzOtpField extends StatefulWidget {
const TripzOtpField({
super.key,
required this.controller,
this.length = 4,
this.onCompleted,
this.enabled = true,
this.hasError = false,
});
final TextEditingController controller;
final int length;
final ValueChanged<String>? onCompleted;
final bool enabled;
final bool hasError;
@override
State<TripzOtpField> createState() => _TripzOtpFieldState();
}
class _TripzOtpFieldState extends State<TripzOtpField> {
final _focus = FocusNode();
@override
void dispose() {
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
// الحقل الفعلي — شفّاف بلا حجم بصري، يستقبل الكيبورد واللصق.
SizedBox(
height: Sizes.control,
child: Opacity(
opacity: 0,
child: TextField(
controller: widget.controller,
focusNode: _focus,
enabled: widget.enabled,
autofocus: true,
keyboardType: TextInputType.number,
maxLength: widget.length,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.oneTimeCode],
onChanged: (v) {
if (v.length == widget.length) widget.onCompleted?.call(v);
},
),
),
),
GestureDetector(
onTap: () => _focus.requestFocus(),
behavior: HitTestBehavior.opaque,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller,
builder: (context, value, _) => Row(
mainAxisAlignment: MainAxisAlignment.center,
textDirection: TextDirection.ltr,
children: List.generate(widget.length, (i) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Space.xxs),
child: _Box(
char: i < value.text.length ? value.text[i] : null,
focused: _focus.hasFocus && i == value.text.length,
hasError: widget.hasError,
),
);
}),
),
),
),
],
);
}
}
class _Box extends StatelessWidget {
const _Box({
required this.char,
required this.focused,
required this.hasError,
});
final String? char;
final bool focused;
final bool hasError;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final borderColor = hasError
? scheme.error
: focused
? scheme.primary
: scheme.outlineVariant;
return AnimatedContainer(
duration: Motion.tap,
curve: Motion.curve,
width: 56,
height: Sizes.control,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: Radii.field_,
border: Border.all(
color: borderColor,
width: focused || hasError ? 1.5 : 1,
),
),
child: Text(
char ?? '',
style: numericStyle(Theme.of(context).textTheme.headlineSmall),
),
);
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import 'empty_view.dart';
import 'error_view.dart';
import 'skeleton.dart';
import 'view_status.dart';
/// نقطة التنظيم المركزية (docs/26 §6). **ممنوع `Scaffold` خام في
/// `features/`.**
///
/// هو ما يحوّل `status` القادم من الـCubit إلى skeleton/خطأ/فراغ/محتوى — فلا
/// تكتب أي شاشة `if (loading)`، وتغيير شكل «التحميل» في التطبيق كله يصير
/// سطراً واحداً هنا.
class TripzScaffold extends StatelessWidget {
const TripzScaffold({
super.key,
required this.child,
this.title,
this.status = ViewStatus.success,
this.error,
this.onRetry,
this.empty,
this.loading,
this.bottomAction,
this.actions,
this.leading,
this.showAppBar = true,
this.padded = true,
this.resizeToAvoidBottomInset = true,
});
/// المحتوى — يُبنى **عند النجاح فقط**.
final Widget child;
final String? title;
final ViewStatus status;
/// رسالة الخطأ كما أصدرها الـCubit، بلا إعادة صياغة في الواجهة.
final String? error;
final VoidCallback? onRetry;
/// شكل الفراغ الخاص بالشاشة — وإلا فالافتراضي.
final Widget? empty;
/// شكل التحميل الخاص بالشاشة — وإلا فهيكل ثلاثة أسطر.
final Widget? loading;
/// خانة الـCTA السفلية: ترتفع فوق الكيبورد، وتحترم المنطقة الآمنة.
final Widget? bottomAction;
final List<Widget>? actions;
final Widget? leading;
final bool showAppBar;
final bool padded;
final bool resizeToAvoidBottomInset;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: !showAppBar
? null
: AppBar(
title: title == null ? null : Text(title!),
actions: actions,
leading: leading,
),
body: SafeArea(
top: !showAppBar,
child: Padding(
padding: padded ? Space.page : EdgeInsets.zero,
child: _Body(
status: status,
error: error,
onRetry: onRetry,
empty: empty,
loading: loading,
child: child,
),
),
),
bottomNavigationBar: bottomAction == null
? null
: SafeArea(
top: false,
child: Padding(
// ‏viewInsets يرفع الـCTA فوق الكيبورد بدل أن يختفي تحته.
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: bottomAction,
),
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.status,
required this.error,
required this.onRetry,
required this.empty,
required this.loading,
required this.child,
});
final ViewStatus status;
final String? error;
final VoidCallback? onRetry;
final Widget? empty;
final Widget? loading;
final Widget child;
@override
Widget build(BuildContext context) {
return switch (status) {
ViewStatus.idle ||
ViewStatus.loading =>
loading ?? const Skeleton.lines(),
ViewStatus.error => ErrorView(message: error, onRetry: onRetry),
ViewStatus.empty => empty ?? const EmptyView(),
ViewStatus.success => child,
};
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// الورقة السفلية — **الحوار الافتراضي في التطبيق sheet لا dialog**
/// (docs/26 §7). المقبض والزوايا من الثيم، فلا تُعاد كتابتهما في كل نداء.
class TripzSheet extends StatelessWidget {
const TripzSheet({
super.key,
required this.child,
this.title,
this.bottomAction,
});
final Widget child;
final String? title;
final Widget? bottomAction;
/// [dismissible] = false للحالات التي لا تُغلق بالسحب — مثل شاشة البحث عن
/// سائق: تُغلق بزر الإلغاء وحده كي لا يزيحها المستخدم بالخطأ (docs/39 §3).
static Future<T?> show<T>(
BuildContext context, {
required Widget child,
String? title,
Widget? bottomAction,
bool dismissible = true,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
isDismissible: dismissible,
enableDrag: dismissible,
showDragHandle: dismissible,
builder: (_) => TripzSheet(
title: title,
bottomAction: bottomAction,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null) ...[
Text(title!, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: Space.md),
],
Flexible(child: child),
if (bottomAction != null) ...[
const SizedBox(height: Space.md),
bottomAction!,
],
],
),
),
);
}
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل الإدخال الموحّد (docs/26 §7): نصّ · هاتف · بحث.
///
/// رسالة الخطأ تحته بالعربية، والأرقام لاتينية tabular دائماً — رقم الهاتف
/// ورمز التحقّق لا «يرقصان» أثناء الكتابة.
class TripzTextField extends StatelessWidget {
const TripzTextField({
super.key,
this.controller,
this.label,
this.hint,
this.errorText,
this.keyboardType,
this.textInputAction,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.enabled = true,
this.maxLength,
this.prefixIcon,
this.suffix,
this.inputFormatters,
this.autofillHints,
this.numeric = false,
});
/// حقل هاتف: أرقام فقط، لوحة مفاتيح هاتف، إكمال تلقائي من النظام.
factory TripzTextField.phone({
Key? key,
TextEditingController? controller,
String? label,
String? hint,
String? errorText,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
bool enabled = true,
}) {
return TripzTextField(
key: key,
controller: controller,
label: label,
hint: hint,
errorText: errorText,
enabled: enabled,
numeric: true,
autofocus: true,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: 15,
prefixIcon: Icons.phone_outlined,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.telephoneNumber],
);
}
final TextEditingController? controller;
final String? label;
final String? hint;
final String? errorText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
final bool enabled;
final int? maxLength;
final IconData? prefixIcon;
final Widget? suffix;
final List<TextInputFormatter>? inputFormatters;
final Iterable<String>? autofillHints;
final bool numeric;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyLarge;
return TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
keyboardType: keyboardType,
textInputAction: textInputAction,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: maxLength,
inputFormatters: inputFormatters,
autofillHints: autofillHints,
style: numeric ? numericStyle(base) : base,
// الأرقام تُقرأ يساراً-ليميناً حتى في واجهة عربية.
textDirection: numeric ? TextDirection.ltr : null,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
counterText: '',
prefixIcon: prefixIcon == null
? null
: Icon(prefixIcon, size: Sizes.iconSm),
suffixIcon: suffix,
),
);
}
}
@@ -0,0 +1,22 @@
/// حالة العرض الموحّدة التي يُصدرها كل Cubit (docs/23 §3) ويستهلكها
/// `TripzScaffold` (docs/26 §6).
///
/// هذا ما يجعل نظام التصميم متوافقاً مع Cubit **بالبنية لا بالاتفاق**:
/// الشاشة لا تكتب `if (loading)` أبداً.
enum ViewStatus {
/// لم يبدأ شيء بعد — يُعرض كتحميل لأن المستخدم لا يفرّق.
idle,
loading,
success,
/// نجح النداء ولا بيانات — يُعرض `EmptyView`.
empty,
error,
}
extension ViewStatusX on ViewStatus {
bool get isLoading => this == ViewStatus.idle || this == ViewStatus.loading;
bool get isError => this == ViewStatus.error;
bool get isEmpty => this == ViewStatus.empty;
bool get isSuccess => this == ViewStatus.success;
}
@@ -0,0 +1,38 @@
import '../../../core/api/api_exception.dart';
/// سبب الفشل كـ**رمز** لا كنصّ.
///
/// docs/23 §3 يقول إن الـCubit يحوّل خطأ الشبكة إلى رسالة عربية؛ وdocs/26 §4
/// (الأحدث، وهو الذي «يفعّل قاعدة docs/23 §2 التي كانت بلا أداة») يوجب مرور
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
/// الشاشة بلا منطق، والنص بلغتين.
enum AuthFailure {
/// رمز خاطئ أو منتهي الصلاحية.
invalidCode,
/// تجاوز عدد المحاولات — الخادم يُبطل الرمز فوراً (docs/38 §2).
tooManyAttempts,
/// تجاوز حدّ طلب الرمز: ثلاثة كل خمس دقائق.
rateLimited,
network,
timeout,
server,
}
extension AuthFailureX on ApiException {
AuthFailure toAuthFailure() {
if (isRateLimited) return AuthFailure.rateLimited;
if (kind == ApiErrorKind.network) return AuthFailure.network;
if (kind == ApiErrorKind.timeout) return AuthFailure.timeout;
if (isUnauthorized) {
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
return message.contains('Too many')
? AuthFailure.tooManyAttempts
: AuthFailure.invalidCode;
}
return AuthFailure.server;
}
}
@@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/config.dart';
import '../data/auth_repository.dart';
import 'auth_failure.dart';
import 'login_state.dart';
import 'phone_error.dart';
/// تدفّق الدخول كاملاً: الشروط ← إذن الموقع ← الهاتف ← الرمز.
///
/// الـCubit لا يعرف Flutter: لا `BuildContext` ولا تنقّل ولا `SnackBar`
/// (docs/23 §3). يُصدر حالة، والواجهة تتصرّف.
class LoginCubit extends Cubit<LoginState> {
LoginCubit(this._repo, this._prefs) : super(const LoginState()) {
_resolveInitialStep();
}
final AuthRepository _repo;
final SharedPreferences _prefs;
static const _kAgreed = 'auth.agreed_terms';
Timer? _resendTimer;
@override
Future<void> close() {
_resendTimer?.cancel();
return super.close();
}
// ── البوابتان ──────────────────────────────────────────────────────────
void _resolveInitialStep() {
if (_prefs.getBool(_kAgreed) != true) {
emit(state.copyWith(step: LoginStep.agreement));
return;
}
emit(state.copyWith(step: LoginStep.permission, agreed: true));
unawaited(checkLocationPermission());
}
void toggleAgreement(bool value) => emit(state.copyWith(agreed: value));
Future<void> acceptAgreement() async {
if (!state.agreed) return;
await _prefs.setBool(_kAgreed, true);
emit(state.copyWith(step: LoginStep.permission));
await checkLocationPermission();
}
/// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى
/// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2).
Future<void> checkLocationPermission() async {
final status = await Permission.locationWhenInUse.status;
if (status.isGranted || status.isLimited) {
emit(state.copyWith(step: LoginStep.phone));
return;
}
emit(state.copyWith(
permissionPermanentlyDenied: status.isPermanentlyDenied,
));
}
Future<void> requestLocationPermission() async {
final status = await Permission.locationWhenInUse.request();
if (status.isGranted || status.isLimited) {
emit(state.copyWith(step: LoginStep.phone));
return;
}
emit(state.copyWith(
permissionPermanentlyDenied: status.isPermanentlyDenied,
));
}
Future<void> openSystemSettings() => openAppSettings();
// ── الهاتف ─────────────────────────────────────────────────────────────
void phoneChanged(String value) {
emit(state.copyWith(
phone: value,
clearPhoneError: true,
clearFailure: true,
));
}
Future<void> submitPhone() async {
final error = validatePhone(state.phone);
if (error != null) {
emit(state.copyWith(phoneError: error));
return;
}
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
await _repo.sendOtp(state.phone.trim());
emit(state.copyWith(status: LoginStatus.idle, step: LoginStep.otp));
_startResendCountdown();
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
}
}
void editPhone() {
_resendTimer?.cancel();
emit(state.copyWith(
step: LoginStep.phone,
status: LoginStatus.idle,
resendIn: 0,
clearFailure: true,
));
}
// ── الرمز ──────────────────────────────────────────────────────────────
Future<void> resendOtp() async {
if (!state.canResend) return;
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
await _repo.sendOtp(state.phone.trim());
emit(state.copyWith(status: LoginStatus.idle));
_startResendCountdown();
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
// فوراً وعمّق الحظر.
_startResendCountdown();
}
}
Future<void> submitOtp(String code) async {
if (code.length != AppConfig.otpLength) return;
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
try {
final user = await _repo.verifyOtp(phone: state.phone.trim(), code: code);
_resendTimer?.cancel();
emit(state.copyWith(
status: LoginStatus.success,
step: LoginStep.done,
user: user,
));
} on ApiException catch (e) {
emit(state.copyWith(
status: LoginStatus.failure,
failure: e.toAuthFailure(),
));
}
}
void _startResendCountdown() {
_resendTimer?.cancel();
emit(state.copyWith(resendIn: AppConfig.otpResendCooldown.inSeconds));
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final next = state.resendIn - 1;
if (next <= 0) timer.cancel();
emit(state.copyWith(resendIn: next < 0 ? 0 : next));
});
}
}
@@ -0,0 +1,87 @@
import 'package:equatable/equatable.dart';
import '../../../core/session/app_user.dart';
import 'auth_failure.dart';
import 'phone_error.dart';
/// خطوات الدخول بالترتيب الميداني المثبت في سيرو (docs/39 §2): بوابتان
/// كاملتان **قبل** أي حقل إدخال، ثم الهاتف فالرمز.
enum LoginStep { agreement, permission, phone, otp, done }
enum LoginStatus { idle, submitting, failure, success }
class LoginState extends Equatable {
const LoginState({
this.step = LoginStep.agreement,
this.status = LoginStatus.idle,
this.phone = '',
this.agreed = false,
this.failure,
this.phoneError,
this.resendIn = 0,
this.permissionPermanentlyDenied = false,
this.user,
});
final LoginStep step;
final LoginStatus status;
/// الرقم كما أدخله المستخدم — الخادم يطبّعه، فلا نطبّعه نحن (docs/38 §2).
final String phone;
final bool agreed;
final AuthFailure? failure;
final PhoneError? phoneError;
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
/// خمس دقائق، فالعدّاد ليس تجميلاً (docs/39 §2).
final int resendIn;
/// رُفض الإذن نهائياً — الحل الوحيد فتح إعدادات النظام.
final bool permissionPermanentlyDenied;
final AppUser? user;
bool get isSubmitting => status == LoginStatus.submitting;
bool get canResend => resendIn == 0 && !isSubmitting;
LoginState copyWith({
LoginStep? step,
LoginStatus? status,
String? phone,
bool? agreed,
AuthFailure? failure,
PhoneError? phoneError,
int? resendIn,
bool? permissionPermanentlyDenied,
AppUser? user,
bool clearFailure = false,
bool clearPhoneError = false,
}) {
return LoginState(
step: step ?? this.step,
status: status ?? this.status,
phone: phone ?? this.phone,
agreed: agreed ?? this.agreed,
failure: clearFailure ? null : (failure ?? this.failure),
phoneError: clearPhoneError ? null : (phoneError ?? this.phoneError),
resendIn: resendIn ?? this.resendIn,
permissionPermanentlyDenied:
permissionPermanentlyDenied ?? this.permissionPermanentlyDenied,
user: user ?? this.user,
);
}
@override
List<Object?> get props => [
step,
status,
phone,
agreed,
failure,
phoneError,
resendIn,
permissionPermanentlyDenied,
user,
];
}
@@ -0,0 +1,11 @@
/// أخطاء تحقّق **محليّة** لرقم الهاتف — تُكتشف قبل أي نداء شبكة.
/// الشروط الثلاثة منقولة من التحقّق المطبَّق ميدانياً في سيرو (docs/39 §2).
enum PhoneError { empty, leadingZero, tooShort }
PhoneError? validatePhone(String raw) {
final v = raw.trim();
if (v.isEmpty) return PhoneError.empty;
if (v.startsWith('0')) return PhoneError.leadingZero;
if (v.length < 9) return PhoneError.tooShort;
return null;
}
@@ -0,0 +1,56 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/ui/view_status.dart';
import '../data/auth_repository.dart';
import '../../../core/session/app_user.dart';
import 'auth_failure.dart';
class ProfileState {
const ProfileState({
this.status = ViewStatus.success,
this.saving = false,
this.failure,
this.user,
});
final ViewStatus status;
final bool saving;
final AuthFailure? failure;
final AppUser? user;
ProfileState copyWith({
ViewStatus? status,
bool? saving,
AuthFailure? failure,
AppUser? user,
bool clearFailure = false,
}) {
return ProfileState(
status: status ?? this.status,
saving: saving ?? this.saving,
failure: clearFailure ? null : (failure ?? this.failure),
user: user ?? this.user,
);
}
}
/// إكمال الملف بعد أول دخول. عقد الخادم يقبل `{ name, language }` فقط
/// (docs/38 §3) — لا اسم أول/أخير ولا بريد كما في سيرو (docs/39 §2).
class ProfileCubit extends Cubit<ProfileState> {
ProfileCubit(this._repo) : super(const ProfileState());
final AuthRepository _repo;
Future<AppUser?> save(String name) async {
emit(state.copyWith(saving: true, clearFailure: true));
try {
final user = await _repo.updateProfile(name: name.trim());
emit(state.copyWith(saving: false, user: user));
return user;
} on ApiException catch (e) {
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
return null;
}
}
}
@@ -0,0 +1,71 @@
import '../../../core/api/api_client.dart';
import '../../../core/storage/token_store.dart';
import '../../../core/session/app_user.dart';
import '../../../core/session/session_source.dart';
/// كل نداءات المصادقة (docs/38 §2). الـCubit لا ينادي Dio مباشرة أبداً
/// (docs/23 §3).
class AuthRepository implements SessionSource {
AuthRepository(this._api, this._tokens);
final ApiClient _api;
final TokenStore _tokens;
/// حدّ الخادم: ثلاثة طلبات كل خمس دقائق — الواجهة تمنع الضغط المتكرر
/// بعدّاد تنازلي (docs/39 §2).
Future<void> sendOtp(String phone) async {
await _api.post<Map<String, dynamic>>(
'/auth/send-otp',
body: {'phone': phone},
);
}
/// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها.
Future<AppUser> verifyOtp({
required String phone,
required String code,
String? referralCode,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/auth/verify-otp',
body: {
'phone': phone,
'code': code,
if (referralCode != null && referralCode.isNotEmpty)
'referral_code': referralCode,
},
);
await _tokens.save(
accessToken: res['access_token'] as String,
refreshToken: res['refresh_token'] as String,
// مفتاح توقيع الطلبات المالية (docs/38 §8) — يُخزَّن الآن ويُستهلك لاحقاً.
signingKey: res['signing_key'] as String?,
);
return AppUser.fromJson(res['user'] as Map<String, dynamic>);
}
@override
Future<AppUser> me() async {
final res = await _api.get<Map<String, dynamic>>('/users/me');
return AppUser.fromJson(res);
}
Future<AppUser> updateProfile({String? name, String? language}) async {
final res = await _api.patch<Map<String, dynamic>>(
'/users/me',
body: {
'name': ?name,
'language': ?language,
},
);
return AppUser.fromJson(res);
}
@override
Future<void> logout() => _tokens.clear();
@override
bool get hasSession => _tokens.isLoggedIn;
}

Some files were not shown because too many files have changed in this diff Show More