From c52338f3bbc3f18c3df6e379ee628e9f0b81097f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 4 Aug 2026 21:42:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=D9=86=D8=B8=D8=A7=D9=85=20=D8=A7?= =?UTF-8?q?=D9=84=D8=AA=D8=B5=D9=85=D9=8A=D9=85=20=D9=88=D8=A7=D9=84=D9=85?= =?UTF-8?q?=D8=B5=D8=A7=D8=AF=D9=82=D8=A9=20=D8=A7=D9=84=D9=83=D8=A7=D9=85?= =?UTF-8?q?=D9=84=D8=A9=20=E2=80=94=20=D8=A7=D9=84=D9=85=D8=B1=D8=AD=D9=84?= =?UTF-8?q?=D8=AA=D8=A7=D9=86=202=20=D9=883?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## تصحيح بنيوي أولاً بنية المرحلة 1 كانت تخالف docs/23 §1 المُلزِم. أُعيدت للشجرة المفروضة حرفياً: app.dart في الجذر · core/{config,build_config,di,router}.dart · core/{design,ui,l10n,api,storage,session}/ · features//{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 --- apps/driver_new/.gitignore | 3 + apps/driver_new/l10n.yaml | 7 + apps/driver_new/lib/app.dart | 54 ++++++ apps/driver_new/lib/app/app.dart | 28 --- apps/driver_new/lib/app/router.dart | 19 -- .../lib/core/{network => api}/api_client.dart | 2 +- .../core/{network => api}/api_exception.dart | 0 .../{network => api}/auth_interceptor.dart | 7 +- apps/driver_new/lib/core/build_config.dart | 40 +++++ .../{config/app_config.dart => config.dart} | 25 +-- apps/driver_new/lib/core/design/theme.dart | 94 ++++++++++ apps/driver_new/lib/core/design/tokens.dart | 69 +++++++ .../lib/core/design/tripz_colors.dart | 129 +++++++++++++ .../lib/core/design/typography.dart | 75 ++++++++ apps/driver_new/lib/core/di.dart | 66 +++++++ apps/driver_new/lib/core/di/injector.dart | 31 ---- apps/driver_new/lib/core/l10n/app_ar.arb | 67 +++++++ apps/driver_new/lib/core/l10n/app_en.arb | 67 +++++++ apps/driver_new/lib/core/l10n/l10n.dart | 10 ++ apps/driver_new/lib/core/router.dart | 78 ++++++++ .../driver_new/lib/core/session/app_user.dart | 50 ++++++ .../lib/core/session/session_cubit.dart | 57 ++++++ .../lib/core/session/session_source.dart | 13 ++ .../lib/core/session/session_state.dart | 32 ++++ apps/driver_new/lib/core/theme/app_theme.dart | 26 --- apps/driver_new/lib/core/ui/empty_view.dart | 54 ++++++ apps/driver_new/lib/core/ui/error_view.dart | 47 +++++ apps/driver_new/lib/core/ui/skeleton.dart | 83 +++++++++ .../driver_new/lib/core/ui/status_banner.dart | 56 ++++++ apps/driver_new/lib/core/ui/tripz_button.dart | 114 ++++++++++++ apps/driver_new/lib/core/ui/tripz_card.dart | 29 +++ .../lib/core/ui/tripz_otp_field.dart | 131 ++++++++++++++ .../lib/core/ui/tripz_scaffold.dart | 130 ++++++++++++++ apps/driver_new/lib/core/ui/tripz_sheet.dart | 71 ++++++++ .../lib/core/ui/tripz_text_field.dart | 109 +++++++++++ apps/driver_new/lib/core/ui/view_status.dart | 22 +++ .../lib/features/auth/cubit/auth_failure.dart | 38 ++++ .../lib/features/auth/cubit/login_cubit.dart | 169 ++++++++++++++++++ .../lib/features/auth/cubit/login_state.dart | 87 +++++++++ .../lib/features/auth/cubit/phone_error.dart | 11 ++ .../features/auth/cubit/profile_cubit.dart | 56 ++++++ .../features/auth/data/auth_repository.dart | 71 ++++++++ .../features/auth/view/agreement_view.dart | 56 ++++++ .../lib/features/auth/view/login_page.dart | 70 ++++++++ .../lib/features/auth/view/otp_view.dart | 89 +++++++++ .../features/auth/view/permission_view.dart | 56 ++++++ .../lib/features/auth/view/phone_view.dart | 61 +++++++ .../lib/features/auth/view/profile_page.dart | 85 +++++++++ .../auth/view/widgets/auth_failure_text.dart | 61 +++++++ .../auth/view/widgets/auth_header.dart | 49 +++++ .../lib/features/home/view/home_page.dart | 58 ++++++ .../settings/cubit/settings_cubit.dart | 37 ++++ .../settings/cubit/settings_state.dart | 24 +++ .../lib/features/splash/splash_page.dart | 50 ------ .../lib/features/splash/view/splash_page.dart | 65 +++++++ apps/driver_new/lib/main.dart | 11 +- apps/driver_new/pubspec.lock | 88 +++++++-- apps/driver_new/pubspec.yaml | 9 +- apps/rider_new/.gitignore | 3 + apps/rider_new/l10n.yaml | 7 + apps/rider_new/lib/app.dart | 54 ++++++ apps/rider_new/lib/app/app.dart | 28 --- apps/rider_new/lib/app/router.dart | 19 -- .../lib/core/{network => api}/api_client.dart | 2 +- .../core/{network => api}/api_exception.dart | 0 .../{network => api}/auth_interceptor.dart | 7 +- apps/rider_new/lib/core/build_config.dart | 40 +++++ .../{config/app_config.dart => config.dart} | 25 +-- apps/rider_new/lib/core/design/theme.dart | 94 ++++++++++ apps/rider_new/lib/core/design/tokens.dart | 69 +++++++ .../lib/core/design/tripz_colors.dart | 129 +++++++++++++ .../rider_new/lib/core/design/typography.dart | 75 ++++++++ apps/rider_new/lib/core/di.dart | 66 +++++++ apps/rider_new/lib/core/di/injector.dart | 31 ---- apps/rider_new/lib/core/l10n/app_ar.arb | 67 +++++++ apps/rider_new/lib/core/l10n/app_en.arb | 67 +++++++ apps/rider_new/lib/core/l10n/l10n.dart | 10 ++ apps/rider_new/lib/core/router.dart | 78 ++++++++ apps/rider_new/lib/core/session/app_user.dart | 50 ++++++ .../lib/core/session/session_cubit.dart | 57 ++++++ .../lib/core/session/session_source.dart | 13 ++ .../lib/core/session/session_state.dart | 32 ++++ apps/rider_new/lib/core/theme/app_theme.dart | 26 --- apps/rider_new/lib/core/ui/empty_view.dart | 54 ++++++ apps/rider_new/lib/core/ui/error_view.dart | 47 +++++ apps/rider_new/lib/core/ui/skeleton.dart | 83 +++++++++ apps/rider_new/lib/core/ui/status_banner.dart | 56 ++++++ apps/rider_new/lib/core/ui/tripz_button.dart | 114 ++++++++++++ apps/rider_new/lib/core/ui/tripz_card.dart | 29 +++ .../lib/core/ui/tripz_otp_field.dart | 131 ++++++++++++++ .../rider_new/lib/core/ui/tripz_scaffold.dart | 130 ++++++++++++++ apps/rider_new/lib/core/ui/tripz_sheet.dart | 71 ++++++++ .../lib/core/ui/tripz_text_field.dart | 109 +++++++++++ apps/rider_new/lib/core/ui/view_status.dart | 22 +++ .../lib/features/auth/cubit/auth_failure.dart | 38 ++++ .../lib/features/auth/cubit/login_cubit.dart | 169 ++++++++++++++++++ .../lib/features/auth/cubit/login_state.dart | 87 +++++++++ .../lib/features/auth/cubit/phone_error.dart | 11 ++ .../features/auth/cubit/profile_cubit.dart | 56 ++++++ .../features/auth/data/auth_repository.dart | 71 ++++++++ .../features/auth/view/agreement_view.dart | 56 ++++++ .../lib/features/auth/view/login_page.dart | 70 ++++++++ .../lib/features/auth/view/otp_view.dart | 89 +++++++++ .../features/auth/view/permission_view.dart | 56 ++++++ .../lib/features/auth/view/phone_view.dart | 61 +++++++ .../lib/features/auth/view/profile_page.dart | 85 +++++++++ .../auth/view/widgets/auth_failure_text.dart | 61 +++++++ .../auth/view/widgets/auth_header.dart | 49 +++++ .../lib/features/home/view/home_page.dart | 58 ++++++ .../settings/cubit/settings_cubit.dart | 37 ++++ .../settings/cubit/settings_state.dart | 24 +++ .../lib/features/splash/splash_page.dart | 50 ------ .../lib/features/splash/view/splash_page.dart | 65 +++++++ apps/rider_new/lib/main.dart | 11 +- apps/rider_new/pubspec.lock | 88 +++++++-- apps/rider_new/pubspec.yaml | 9 +- docs/37-newapps-cubit-plan.md | 43 ++++- 117 files changed, 6061 insertions(+), 374 deletions(-) create mode 100644 apps/driver_new/l10n.yaml create mode 100644 apps/driver_new/lib/app.dart delete mode 100644 apps/driver_new/lib/app/app.dart delete mode 100644 apps/driver_new/lib/app/router.dart rename apps/driver_new/lib/core/{network => api}/api_client.dart (97%) rename apps/driver_new/lib/core/{network => api}/api_exception.dart (100%) rename apps/driver_new/lib/core/{network => api}/auth_interceptor.dart (96%) create mode 100644 apps/driver_new/lib/core/build_config.dart rename apps/driver_new/lib/core/{config/app_config.dart => config.dart} (51%) create mode 100644 apps/driver_new/lib/core/design/theme.dart create mode 100644 apps/driver_new/lib/core/design/tokens.dart create mode 100644 apps/driver_new/lib/core/design/tripz_colors.dart create mode 100644 apps/driver_new/lib/core/design/typography.dart create mode 100644 apps/driver_new/lib/core/di.dart delete mode 100644 apps/driver_new/lib/core/di/injector.dart create mode 100644 apps/driver_new/lib/core/l10n/app_ar.arb create mode 100644 apps/driver_new/lib/core/l10n/app_en.arb create mode 100644 apps/driver_new/lib/core/l10n/l10n.dart create mode 100644 apps/driver_new/lib/core/router.dart create mode 100644 apps/driver_new/lib/core/session/app_user.dart create mode 100644 apps/driver_new/lib/core/session/session_cubit.dart create mode 100644 apps/driver_new/lib/core/session/session_source.dart create mode 100644 apps/driver_new/lib/core/session/session_state.dart delete mode 100644 apps/driver_new/lib/core/theme/app_theme.dart create mode 100644 apps/driver_new/lib/core/ui/empty_view.dart create mode 100644 apps/driver_new/lib/core/ui/error_view.dart create mode 100644 apps/driver_new/lib/core/ui/skeleton.dart create mode 100644 apps/driver_new/lib/core/ui/status_banner.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_button.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_card.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_otp_field.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_scaffold.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_sheet.dart create mode 100644 apps/driver_new/lib/core/ui/tripz_text_field.dart create mode 100644 apps/driver_new/lib/core/ui/view_status.dart create mode 100644 apps/driver_new/lib/features/auth/cubit/auth_failure.dart create mode 100644 apps/driver_new/lib/features/auth/cubit/login_cubit.dart create mode 100644 apps/driver_new/lib/features/auth/cubit/login_state.dart create mode 100644 apps/driver_new/lib/features/auth/cubit/phone_error.dart create mode 100644 apps/driver_new/lib/features/auth/cubit/profile_cubit.dart create mode 100644 apps/driver_new/lib/features/auth/data/auth_repository.dart create mode 100644 apps/driver_new/lib/features/auth/view/agreement_view.dart create mode 100644 apps/driver_new/lib/features/auth/view/login_page.dart create mode 100644 apps/driver_new/lib/features/auth/view/otp_view.dart create mode 100644 apps/driver_new/lib/features/auth/view/permission_view.dart create mode 100644 apps/driver_new/lib/features/auth/view/phone_view.dart create mode 100644 apps/driver_new/lib/features/auth/view/profile_page.dart create mode 100644 apps/driver_new/lib/features/auth/view/widgets/auth_failure_text.dart create mode 100644 apps/driver_new/lib/features/auth/view/widgets/auth_header.dart create mode 100644 apps/driver_new/lib/features/home/view/home_page.dart create mode 100644 apps/driver_new/lib/features/settings/cubit/settings_cubit.dart create mode 100644 apps/driver_new/lib/features/settings/cubit/settings_state.dart delete mode 100644 apps/driver_new/lib/features/splash/splash_page.dart create mode 100644 apps/driver_new/lib/features/splash/view/splash_page.dart create mode 100644 apps/rider_new/l10n.yaml create mode 100644 apps/rider_new/lib/app.dart delete mode 100644 apps/rider_new/lib/app/app.dart delete mode 100644 apps/rider_new/lib/app/router.dart rename apps/rider_new/lib/core/{network => api}/api_client.dart (97%) rename apps/rider_new/lib/core/{network => api}/api_exception.dart (100%) rename apps/rider_new/lib/core/{network => api}/auth_interceptor.dart (96%) create mode 100644 apps/rider_new/lib/core/build_config.dart rename apps/rider_new/lib/core/{config/app_config.dart => config.dart} (51%) create mode 100644 apps/rider_new/lib/core/design/theme.dart create mode 100644 apps/rider_new/lib/core/design/tokens.dart create mode 100644 apps/rider_new/lib/core/design/tripz_colors.dart create mode 100644 apps/rider_new/lib/core/design/typography.dart create mode 100644 apps/rider_new/lib/core/di.dart delete mode 100644 apps/rider_new/lib/core/di/injector.dart create mode 100644 apps/rider_new/lib/core/l10n/app_ar.arb create mode 100644 apps/rider_new/lib/core/l10n/app_en.arb create mode 100644 apps/rider_new/lib/core/l10n/l10n.dart create mode 100644 apps/rider_new/lib/core/router.dart create mode 100644 apps/rider_new/lib/core/session/app_user.dart create mode 100644 apps/rider_new/lib/core/session/session_cubit.dart create mode 100644 apps/rider_new/lib/core/session/session_source.dart create mode 100644 apps/rider_new/lib/core/session/session_state.dart delete mode 100644 apps/rider_new/lib/core/theme/app_theme.dart create mode 100644 apps/rider_new/lib/core/ui/empty_view.dart create mode 100644 apps/rider_new/lib/core/ui/error_view.dart create mode 100644 apps/rider_new/lib/core/ui/skeleton.dart create mode 100644 apps/rider_new/lib/core/ui/status_banner.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_button.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_card.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_otp_field.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_scaffold.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_sheet.dart create mode 100644 apps/rider_new/lib/core/ui/tripz_text_field.dart create mode 100644 apps/rider_new/lib/core/ui/view_status.dart create mode 100644 apps/rider_new/lib/features/auth/cubit/auth_failure.dart create mode 100644 apps/rider_new/lib/features/auth/cubit/login_cubit.dart create mode 100644 apps/rider_new/lib/features/auth/cubit/login_state.dart create mode 100644 apps/rider_new/lib/features/auth/cubit/phone_error.dart create mode 100644 apps/rider_new/lib/features/auth/cubit/profile_cubit.dart create mode 100644 apps/rider_new/lib/features/auth/data/auth_repository.dart create mode 100644 apps/rider_new/lib/features/auth/view/agreement_view.dart create mode 100644 apps/rider_new/lib/features/auth/view/login_page.dart create mode 100644 apps/rider_new/lib/features/auth/view/otp_view.dart create mode 100644 apps/rider_new/lib/features/auth/view/permission_view.dart create mode 100644 apps/rider_new/lib/features/auth/view/phone_view.dart create mode 100644 apps/rider_new/lib/features/auth/view/profile_page.dart create mode 100644 apps/rider_new/lib/features/auth/view/widgets/auth_failure_text.dart create mode 100644 apps/rider_new/lib/features/auth/view/widgets/auth_header.dart create mode 100644 apps/rider_new/lib/features/home/view/home_page.dart create mode 100644 apps/rider_new/lib/features/settings/cubit/settings_cubit.dart create mode 100644 apps/rider_new/lib/features/settings/cubit/settings_state.dart delete mode 100644 apps/rider_new/lib/features/splash/splash_page.dart create mode 100644 apps/rider_new/lib/features/splash/view/splash_page.dart diff --git a/apps/driver_new/.gitignore b/apps/driver_new/.gitignore index 3820a95..e64ceb9 100644 --- a/apps/driver_new/.gitignore +++ b/apps/driver_new/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# مُولَّد من ARB — لا يُحرَّر +lib/core/l10n/gen/ diff --git a/apps/driver_new/l10n.yaml b/apps/driver_new/l10n.yaml new file mode 100644 index 0000000..c22781d --- /dev/null +++ b/apps/driver_new/l10n.yaml @@ -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 diff --git a/apps/driver_new/lib/app.dart b/apps/driver_new/lib/app.dart new file mode 100644 index 0000000..822088d --- /dev/null +++ b/apps/driver_new/lib/app.dart @@ -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(), + child: BlocBuilder( + 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(), + ), + ); + }, + ), + ); + } +} diff --git a/apps/driver_new/lib/app/app.dart b/apps/driver_new/lib/app/app.dart deleted file mode 100644 index a4c82c4..0000000 --- a/apps/driver_new/lib/app/app.dart +++ /dev/null @@ -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, - ], - ); - } -} diff --git a/apps/driver_new/lib/app/router.dart b/apps/driver_new/lib/app/router.dart deleted file mode 100644 index a16ed24..0000000 --- a/apps/driver_new/lib/app/router.dart +++ /dev/null @@ -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(), - ), - ], -); diff --git a/apps/driver_new/lib/core/network/api_client.dart b/apps/driver_new/lib/core/api/api_client.dart similarity index 97% rename from apps/driver_new/lib/core/network/api_client.dart rename to apps/driver_new/lib/core/api/api_client.dart index 7b96ded..6f03250 100644 --- a/apps/driver_new/lib/core/network/api_client.dart +++ b/apps/driver_new/lib/core/api/api_client.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../config/app_config.dart'; +import '../config.dart'; import '../storage/token_store.dart'; import 'api_exception.dart'; import 'auth_interceptor.dart'; diff --git a/apps/driver_new/lib/core/network/api_exception.dart b/apps/driver_new/lib/core/api/api_exception.dart similarity index 100% rename from apps/driver_new/lib/core/network/api_exception.dart rename to apps/driver_new/lib/core/api/api_exception.dart diff --git a/apps/driver_new/lib/core/network/auth_interceptor.dart b/apps/driver_new/lib/core/api/auth_interceptor.dart similarity index 96% rename from apps/driver_new/lib/core/network/auth_interceptor.dart rename to apps/driver_new/lib/core/api/auth_interceptor.dart index 7ee3f2e..4663e27 100644 --- a/apps/driver_new/lib/core/network/auth_interceptor.dart +++ b/apps/driver_new/lib/core/api/auth_interceptor.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'package:dio/dio.dart'; -import '../config/app_config.dart'; +import '../build_config.dart'; +import '../config.dart'; import '../storage/token_store.dart'; /// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه. @@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor { RequestOptions options, RequestInterceptorHandler handler, ) async { - options.headers['x-tenant-id'] = AppConfig.tenantSlug; + options.headers['x-tenant-id'] = BuildConfig.tenantSlug; options.headers['x-app-role'] = AppConfig.appRole; final deviceId = await _tokens.deviceId(); @@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor { '/auth/refresh', data: {'refresh_token': refresh}, options: Options(headers: { - 'x-tenant-id': AppConfig.tenantSlug, + 'x-tenant-id': BuildConfig.tenantSlug, 'x-app-role': AppConfig.appRole, 'x-device-id': ?deviceId, }), diff --git a/apps/driver_new/lib/core/build_config.dart b/apps/driver_new/lib/core/build_config.dart new file mode 100644 index 0000000..cb9a611 --- /dev/null +++ b/apps/driver_new/lib/core/build_config.dart @@ -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; +} diff --git a/apps/driver_new/lib/core/config/app_config.dart b/apps/driver_new/lib/core/config.dart similarity index 51% rename from apps/driver_new/lib/core/config/app_config.dart rename to apps/driver_new/lib/core/config.dart index 7f213b6..53c07b4 100644 --- a/apps/driver_new/lib/core/config/app_config.dart +++ b/apps/driver_new/lib/core/config.dart @@ -1,11 +1,11 @@ -/// إعداد البناء — كل قيمة هنا `const` تُمرَّر عبر `--dart-define` عند البناء. +/// إعداد وقت التشغيل — يُمرَّر بـ`--dart-define` عند البناء (docs/23 §1). /// -/// هذا هو أساس نموذج lite/pro/max (docs/37 §6.25): بناء مولَّد بأعلام ثابتة، -/// لا فروع كود. القيم الافتراضية تشير للسيرفر التجريبي. +/// ما يخصّ **هوية المستأجر البصرية** (لون · لوغو · اسم) يعيش في +/// `build_config.dart` المُولَّد، لا هنا. class AppConfig { const AppConfig._(); - /// أصل الـAPI **مع** `/api` — كما في docs/38. + /// أصل الـAPI **مع** `/api` (docs/38). static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://tripz-api.intaleqapp.com/api', @@ -17,18 +17,19 @@ class AppConfig { defaultValue: 'https://tripz-api.intaleqapp.com', ); - /// **slug** المستأجر لا الـUUID — مصيدة docs/38 §12.9. - static const String tenantSlug = String.fromEnvironment( - 'TENANT_SLUG', - defaultValue: 'siro', - ); - /// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان /// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.** static const String appRole = 'driver'; - /// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار - /// حتى 401 يعني فشل طلب في منتصف رحلة. + /// طول رمز التحقّق كما يولّده الخادم (docs/38 §2). + 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 connectTimeout = Duration(seconds: 20); diff --git a/apps/driver_new/lib/core/design/theme.dart b/apps/driver_new/lib/core/design/theme.dart new file mode 100644 index 0000000..f375f8e --- /dev/null +++ b/apps/driver_new/lib/core/design/theme.dart @@ -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), + ); +} diff --git a/apps/driver_new/lib/core/design/tokens.dart b/apps/driver_new/lib/core/design/tokens.dart new file mode 100644 index 0000000..3ae168d --- /dev/null +++ b/apps/driver_new/lib/core/design/tokens.dart @@ -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; +} diff --git a/apps/driver_new/lib/core/design/tripz_colors.dart b/apps/driver_new/lib/core/design/tripz_colors.dart new file mode 100644 index 0000000..23c9cdb --- /dev/null +++ b/apps/driver_new/lib/core/design/tripz_colors.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2). +/// +/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها، +/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر +/// كل المستأجرين كما يفرض عقد §0. +@immutable +class TripzColors extends ThemeExtension { + 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? 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()!; + ColorScheme get colors => Theme.of(this).colorScheme; + TextTheme get texts => Theme.of(this).textTheme; +} diff --git a/apps/driver_new/lib/core/design/typography.dart b/apps/driver_new/lib/core/design/typography.dart new file mode 100644 index 0000000..cd53abf --- /dev/null +++ b/apps/driver_new/lib/core/design/typography.dart @@ -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 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, + ); diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart new file mode 100644 index 0000000..f042280 --- /dev/null +++ b/apps/driver_new/lib/core/di.dart @@ -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 setupInjector() async { + final prefs = await SharedPreferences.getInstance(); + sl.registerSingleton(prefs); + + const secure = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + final tokens = TokenStore(secure); + await tokens.load(); + await _ensureDeviceId(tokens); + sl.registerSingleton(tokens); + + sl.registerSingleton(SettingsCubit(prefs)); + + // انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر + // يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor. + sl.registerSingleton( + ApiClient.create( + tokens: tokens, + onSessionExpired: () async => sl().onSessionExpired(), + ), + ); + + sl.registerSingleton(AuthRepository(sl(), sl())); + sl.registerSingleton(SessionCubit(sl())); + + // تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية. + sl.registerFactory(() => LoginCubit(sl(), sl())); +} + +/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل +/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل +/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**. +Future _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); +} diff --git a/apps/driver_new/lib/core/di/injector.dart b/apps/driver_new/lib/core/di/injector.dart deleted file mode 100644 index 257480c..0000000 --- a/apps/driver_new/lib/core/di/injector.dart +++ /dev/null @@ -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 setupInjector() async { - sl.registerSingleton( - await SharedPreferences.getInstance(), - ); - - const secure = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), - ); - - final tokens = TokenStore(secure); - await tokens.load(); - sl.registerSingleton(tokens); - - sl.registerSingleton( - ApiClient.create( - tokens: tokens, - // يُربط بالتوجيه في م3 (إخراج للمستخدم عند انتهاء الجلسة). - onSessionExpired: () async {}, - ), - ); -} diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb new file mode 100644 index 0000000..6007d81 --- /dev/null +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -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" +} diff --git a/apps/driver_new/lib/core/l10n/app_en.arb b/apps/driver_new/lib/core/l10n/app_en.arb new file mode 100644 index 0000000..7074e55 --- /dev/null +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -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" +} diff --git a/apps/driver_new/lib/core/l10n/l10n.dart b/apps/driver_new/lib/core/l10n/l10n.dart new file mode 100644 index 0000000..abd1e2c --- /dev/null +++ b/apps/driver_new/lib/core/l10n/l10n.dart @@ -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); +} diff --git a/apps/driver_new/lib/core/router.dart b/apps/driver_new/lib/core/router.dart new file mode 100644 index 0000000..8284422 --- /dev/null +++ b/apps/driver_new/lib/core/router.dart @@ -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().stream), + redirect: (context, state) { + final session = sl().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 stream) { + _sub = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _sub; + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } +} diff --git a/apps/driver_new/lib/core/session/app_user.dart b/apps/driver_new/lib/core/session/app_user.dart new file mode 100644 index 0000000..d6aac53 --- /dev/null +++ b/apps/driver_new/lib/core/session/app_user.dart @@ -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 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 get props => [id, tenantId, phone, role, status, language, name]; +} diff --git a/apps/driver_new/lib/core/session/session_cubit.dart b/apps/driver_new/lib/core/session/session_cubit.dart new file mode 100644 index 0000000..51ef5a5 --- /dev/null +++ b/apps/driver_new/lib/core/session/session_cubit.dart @@ -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 { + SessionCubit(this._repo) : super(const SessionState()); + + final SessionSource _repo; + + /// تُستدعى مرة عند الإقلاع من شاشة الـsplash. + Future 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 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, + )); + } +} diff --git a/apps/driver_new/lib/core/session/session_source.dart b/apps/driver_new/lib/core/session/session_source.dart new file mode 100644 index 0000000..8366f5c --- /dev/null +++ b/apps/driver_new/lib/core/session/session_source.dart @@ -0,0 +1,13 @@ +import 'app_user.dart'; + +/// ما تحتاجه الجلسة من طبقة المصادقة — **ولا شيء أكثر**. +/// +/// `SessionCubit` حالةٌ على مستوى التطبيق (الراوتر والشاشات كلها تقرأها)، +/// فمحلّها `core/`. ولأن `core/` لا يجوز أن يستورد من `features/` كما لا +/// تستورد ميزةٌ من أخرى (docs/23 §1)، تُعرّف الحاجة هنا كواجهة ضيّقة +/// ويُنفّذها `AuthRepository` في ميزة المصادقة. +abstract interface class SessionSource { + Future me(); + Future logout(); + bool get hasSession; +} diff --git a/apps/driver_new/lib/core/session/session_state.dart b/apps/driver_new/lib/core/session/session_state.dart new file mode 100644 index 0000000..a6567f3 --- /dev/null +++ b/apps/driver_new/lib/core/session/session_state.dart @@ -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 get props => [status, user]; +} diff --git a/apps/driver_new/lib/core/theme/app_theme.dart b/apps/driver_new/lib/core/theme/app_theme.dart deleted file mode 100644 index e082d46..0000000 --- a/apps/driver_new/lib/core/theme/app_theme.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/apps/driver_new/lib/core/ui/empty_view.dart b/apps/driver_new/lib/core/ui/empty_view.dart new file mode 100644 index 0000000..bae9cdb --- /dev/null +++ b/apps/driver_new/lib/core/ui/empty_view.dart @@ -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), + ], + ], + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/error_view.dart b/apps/driver_new/lib/core/ui/error_view.dart new file mode 100644 index 0000000..0e0eb7c --- /dev/null +++ b/apps/driver_new/lib/core/ui/error_view.dart @@ -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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/skeleton.dart b/apps/driver_new/lib/core/ui/skeleton.dart new file mode 100644 index 0000000..9c5fddb --- /dev/null +++ b/apps/driver_new/lib/core/ui/skeleton.dart @@ -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 createState() => _SkeletonState(); +} + +class _SkeletonState extends State + 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), + ), + ); +} diff --git a/apps/driver_new/lib/core/ui/status_banner.dart b/apps/driver_new/lib/core/ui/status_banner.dart new file mode 100644 index 0000000..b2e3c9d --- /dev/null +++ b/apps/driver_new/lib/core/ui/status_banner.dart @@ -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, + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_button.dart b/apps/driver_new/lib/core/ui/tripz_button.dart new file mode 100644 index 0000000..76e4bf2 --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_button.dart @@ -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), + ], + ); + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_card.dart b/apps/driver_new/lib/core/ui/tripz_card.dart new file mode 100644 index 0000000..9bf35ca --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_card.dart @@ -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), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_otp_field.dart b/apps/driver_new/lib/core/ui/tripz_otp_field.dart new file mode 100644 index 0000000..0779f88 --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_otp_field.dart @@ -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? onCompleted; + final bool enabled; + final bool hasError; + + @override + State createState() => _TripzOtpFieldState(); +} + +class _TripzOtpFieldState extends State { + 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( + 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), + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_scaffold.dart b/apps/driver_new/lib/core/ui/tripz_scaffold.dart new file mode 100644 index 0000000..8e5fdcf --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_scaffold.dart @@ -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? 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, + }; + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_sheet.dart b/apps/driver_new/lib/core/ui/tripz_sheet.dart new file mode 100644 index 0000000..331e8bb --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_sheet.dart @@ -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 show( + BuildContext context, { + required Widget child, + String? title, + Widget? bottomAction, + bool dismissible = true, + }) { + return showModalBottomSheet( + 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!, + ], + ], + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/tripz_text_field.dart b/apps/driver_new/lib/core/ui/tripz_text_field.dart new file mode 100644 index 0000000..b0e0acb --- /dev/null +++ b/apps/driver_new/lib/core/ui/tripz_text_field.dart @@ -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? onChanged, + ValueChanged? 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? onChanged; + final ValueChanged? onSubmitted; + final bool autofocus; + final bool enabled; + final int? maxLength; + final IconData? prefixIcon; + final Widget? suffix; + final List? inputFormatters; + final Iterable? 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, + ), + ); + } +} diff --git a/apps/driver_new/lib/core/ui/view_status.dart b/apps/driver_new/lib/core/ui/view_status.dart new file mode 100644 index 0000000..d94443c --- /dev/null +++ b/apps/driver_new/lib/core/ui/view_status.dart @@ -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; +} diff --git a/apps/driver_new/lib/features/auth/cubit/auth_failure.dart b/apps/driver_new/lib/features/auth/cubit/auth_failure.dart new file mode 100644 index 0000000..2efe08f --- /dev/null +++ b/apps/driver_new/lib/features/auth/cubit/auth_failure.dart @@ -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; + } +} diff --git a/apps/driver_new/lib/features/auth/cubit/login_cubit.dart b/apps/driver_new/lib/features/auth/cubit/login_cubit.dart new file mode 100644 index 0000000..389a2d4 --- /dev/null +++ b/apps/driver_new/lib/features/auth/cubit/login_cubit.dart @@ -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 { + 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 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 acceptAgreement() async { + if (!state.agreed) return; + await _prefs.setBool(_kAgreed, true); + emit(state.copyWith(step: LoginStep.permission)); + await checkLocationPermission(); + } + + /// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى + /// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2). + Future 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 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 openSystemSettings() => openAppSettings(); + + // ── الهاتف ───────────────────────────────────────────────────────────── + + void phoneChanged(String value) { + emit(state.copyWith( + phone: value, + clearPhoneError: true, + clearFailure: true, + )); + } + + Future 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 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 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)); + }); + } +} diff --git a/apps/driver_new/lib/features/auth/cubit/login_state.dart b/apps/driver_new/lib/features/auth/cubit/login_state.dart new file mode 100644 index 0000000..9621f23 --- /dev/null +++ b/apps/driver_new/lib/features/auth/cubit/login_state.dart @@ -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 get props => [ + step, + status, + phone, + agreed, + failure, + phoneError, + resendIn, + permissionPermanentlyDenied, + user, + ]; +} diff --git a/apps/driver_new/lib/features/auth/cubit/phone_error.dart b/apps/driver_new/lib/features/auth/cubit/phone_error.dart new file mode 100644 index 0000000..ad8ac94 --- /dev/null +++ b/apps/driver_new/lib/features/auth/cubit/phone_error.dart @@ -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; +} diff --git a/apps/driver_new/lib/features/auth/cubit/profile_cubit.dart b/apps/driver_new/lib/features/auth/cubit/profile_cubit.dart new file mode 100644 index 0000000..6e16708 --- /dev/null +++ b/apps/driver_new/lib/features/auth/cubit/profile_cubit.dart @@ -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 { + ProfileCubit(this._repo) : super(const ProfileState()); + + final AuthRepository _repo; + + Future 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; + } + } +} diff --git a/apps/driver_new/lib/features/auth/data/auth_repository.dart b/apps/driver_new/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..5af7e4f --- /dev/null +++ b/apps/driver_new/lib/features/auth/data/auth_repository.dart @@ -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 sendOtp(String phone) async { + await _api.post>( + '/auth/send-otp', + body: {'phone': phone}, + ); + } + + /// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها. + Future verifyOtp({ + required String phone, + required String code, + String? referralCode, + }) async { + final res = await _api.post>( + '/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); + } + + @override + Future me() async { + final res = await _api.get>('/users/me'); + return AppUser.fromJson(res); + } + + Future updateProfile({String? name, String? language}) async { + final res = await _api.patch>( + '/users/me', + body: { + 'name': ?name, + 'language': ?language, + }, + ); + return AppUser.fromJson(res); + } + + @override + Future logout() => _tokens.clear(); + + @override + bool get hasSession => _tokens.isLoggedIn; +} diff --git a/apps/driver_new/lib/features/auth/view/agreement_view.dart b/apps/driver_new/lib/features/auth/view/agreement_view.dart new file mode 100644 index 0000000..1f31675 --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/agreement_view.dart @@ -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( + buildWhen: (p, n) => p.agreed != n.agreed, + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/login_page.dart b/apps/driver_new/lib/features/auth/view/login_page.dart new file mode 100644 index 0000000..64c4c38 --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State with WidgetsBindingObserver { + late final LoginCubit _cubit = sl(); + + @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( + listenWhen: (prev, next) => next.step == LoginStep.done, + listener: (context, state) { + final user = state.user; + if (user != null) context.read().onLoggedIn(user); + }, + child: BlocBuilder( + 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(), + }, + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/otp_view.dart b/apps/driver_new/lib/features/auth/view/otp_view.dart new file mode 100644 index 0000000..bc10912 --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/otp_view.dart @@ -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 createState() => _OtpViewState(); +} + +class _OtpViewState extends State { + final _code = TextEditingController(); + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/permission_view.dart b/apps/driver_new/lib/features/auth/view/permission_view.dart new file mode 100644 index 0000000..f8f4b2e --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/permission_view.dart @@ -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( + buildWhen: (p, n) => + p.permissionPermanentlyDenied != n.permissionPermanentlyDenied, + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ], + ], + ), + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/phone_view.dart b/apps/driver_new/lib/features/auth/view/phone_view.dart new file mode 100644 index 0000000..8a7e7dd --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/phone_view.dart @@ -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( + buildWhen: (p, n) => + p.status != n.status || + p.phoneError != n.phoneError || + p.failure != n.failure, + builder: (context, state) { + final cubit = context.read(); + 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!), + ], + ), + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/profile_page.dart b/apps/driver_new/lib/features/auth/view/profile_page.dart new file mode 100644 index 0000000..9664276 --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/profile_page.dart @@ -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 createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + 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( + 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 _submit(BuildContext context) async { + if (_name.text.trim().isEmpty) { + setState(() => _error = context.l10n.profileNameErrEmpty); + return; + } + final session = context.read(); + final user = await context.read().save(_name.text); + if (user != null) session.onProfileCompleted(user); + } +} diff --git a/apps/driver_new/lib/features/auth/view/widgets/auth_failure_text.dart b/apps/driver_new/lib/features/auth/view/widgets/auth_failure_text.dart new file mode 100644 index 0000000..6936ea3 --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/widgets/auth_failure_text.dart @@ -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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/auth/view/widgets/auth_header.dart b/apps/driver_new/lib/features/auth/view/widgets/auth_header.dart new file mode 100644 index 0000000..1de0d2d --- /dev/null +++ b/apps/driver_new/lib/features/auth/view/widgets/auth_header.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/apps/driver_new/lib/features/home/view/home_page.dart b/apps/driver_new/lib/features/home/view/home_page.dart new file mode 100644 index 0000000..04a9fac --- /dev/null +++ b/apps/driver_new/lib/features/home/view/home_page.dart @@ -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( + 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().logout, + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/settings/cubit/settings_cubit.dart b/apps/driver_new/lib/features/settings/cubit/settings_cubit.dart new file mode 100644 index 0000000..4875dff --- /dev/null +++ b/apps/driver_new/lib/features/settings/cubit/settings_cubit.dart @@ -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 { + 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 setThemeMode(ThemeMode mode) async { + emit(state.copyWith(themeMode: mode)); + await _prefs.setString(_kTheme, mode.name); + } + + Future setLocale(Locale locale) async { + emit(state.copyWith(locale: locale)); + await _prefs.setString(_kLocale, locale.languageCode); + } +} diff --git a/apps/driver_new/lib/features/settings/cubit/settings_state.dart b/apps/driver_new/lib/features/settings/cubit/settings_state.dart new file mode 100644 index 0000000..f841895 --- /dev/null +++ b/apps/driver_new/lib/features/settings/cubit/settings_state.dart @@ -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 get props => [themeMode, locale]; +} diff --git a/apps/driver_new/lib/features/splash/splash_page.dart b/apps/driver_new/lib/features/splash/splash_page.dart deleted file mode 100644 index 9854908..0000000 --- a/apps/driver_new/lib/features/splash/splash_page.dart +++ /dev/null @@ -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), - ), - ], - ), - ), - ); - } -} diff --git a/apps/driver_new/lib/features/splash/view/splash_page.dart b/apps/driver_new/lib/features/splash/view/splash_page.dart new file mode 100644 index 0000000..c8e5341 --- /dev/null +++ b/apps/driver_new/lib/features/splash/view/splash_page.dart @@ -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 createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + // بعد أول إطار: `restore` قد يُصدر حالة فوراً، وإصدارها أثناء البناء خطأ. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().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), + ), + ], + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/main.dart b/apps/driver_new/lib/main.dart index b5a5ff8..955cded 100644 --- a/apps/driver_new/lib/main.dart +++ b/apps/driver_new/lib/main.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import 'app/app.dart'; -import 'core/di/injector.dart'; +import 'app.dart'; +import 'core/di.dart'; +import 'core/session/session_cubit.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await setupInjector(); - runApp(const TripzApp()); + runApp( + // الجلسة فوق الجميع: الراوتر والشاشات يقرآنها من نقطة واحدة. + BlocProvider.value(value: sl(), child: const TripzApp()), + ); } diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index f21bfbd..9eb5b8c 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -81,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -121,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -207,10 +215,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -437,6 +445,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -622,10 +678,18 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" 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: dependency: transitive description: diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index acbab90..ee743bf 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -29,10 +29,14 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + # م3: الأذونات وبصمة الجهاز + permission_handler: ^12.0.1 + device_info_plus: ^12.3.0 + cupertino_icons: ^1.0.8 # باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7): -# م3: device_info_plus · crypto (توقيع الطلبات وبصمة الجهاز) +# م3 لاحقاً: crypto (توقيع طلبات النقاط المالية) # م4: الخريطة · geolocator · socket_io_client # م5: التقييم والمحفظة م6: الإشعارات والدردشة @@ -44,6 +48,9 @@ dev_dependencies: flutter: uses-material-design: true + # توليد الترجمة من ARB (docs/26 §4) + generate: true + assets: - assets/ - assets/images/ diff --git a/apps/rider_new/.gitignore b/apps/rider_new/.gitignore index 3820a95..e64ceb9 100644 --- a/apps/rider_new/.gitignore +++ b/apps/rider_new/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# مُولَّد من ARB — لا يُحرَّر +lib/core/l10n/gen/ diff --git a/apps/rider_new/l10n.yaml b/apps/rider_new/l10n.yaml new file mode 100644 index 0000000..c22781d --- /dev/null +++ b/apps/rider_new/l10n.yaml @@ -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 diff --git a/apps/rider_new/lib/app.dart b/apps/rider_new/lib/app.dart new file mode 100644 index 0000000..822088d --- /dev/null +++ b/apps/rider_new/lib/app.dart @@ -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(), + child: BlocBuilder( + 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(), + ), + ); + }, + ), + ); + } +} diff --git a/apps/rider_new/lib/app/app.dart b/apps/rider_new/lib/app/app.dart deleted file mode 100644 index a4c82c4..0000000 --- a/apps/rider_new/lib/app/app.dart +++ /dev/null @@ -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, - ], - ); - } -} diff --git a/apps/rider_new/lib/app/router.dart b/apps/rider_new/lib/app/router.dart deleted file mode 100644 index a16ed24..0000000 --- a/apps/rider_new/lib/app/router.dart +++ /dev/null @@ -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(), - ), - ], -); diff --git a/apps/rider_new/lib/core/network/api_client.dart b/apps/rider_new/lib/core/api/api_client.dart similarity index 97% rename from apps/rider_new/lib/core/network/api_client.dart rename to apps/rider_new/lib/core/api/api_client.dart index 7b96ded..6f03250 100644 --- a/apps/rider_new/lib/core/network/api_client.dart +++ b/apps/rider_new/lib/core/api/api_client.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../config/app_config.dart'; +import '../config.dart'; import '../storage/token_store.dart'; import 'api_exception.dart'; import 'auth_interceptor.dart'; diff --git a/apps/rider_new/lib/core/network/api_exception.dart b/apps/rider_new/lib/core/api/api_exception.dart similarity index 100% rename from apps/rider_new/lib/core/network/api_exception.dart rename to apps/rider_new/lib/core/api/api_exception.dart diff --git a/apps/rider_new/lib/core/network/auth_interceptor.dart b/apps/rider_new/lib/core/api/auth_interceptor.dart similarity index 96% rename from apps/rider_new/lib/core/network/auth_interceptor.dart rename to apps/rider_new/lib/core/api/auth_interceptor.dart index 7ee3f2e..4663e27 100644 --- a/apps/rider_new/lib/core/network/auth_interceptor.dart +++ b/apps/rider_new/lib/core/api/auth_interceptor.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'package:dio/dio.dart'; -import '../config/app_config.dart'; +import '../build_config.dart'; +import '../config.dart'; import '../storage/token_store.dart'; /// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه. @@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor { RequestOptions options, RequestInterceptorHandler handler, ) async { - options.headers['x-tenant-id'] = AppConfig.tenantSlug; + options.headers['x-tenant-id'] = BuildConfig.tenantSlug; options.headers['x-app-role'] = AppConfig.appRole; final deviceId = await _tokens.deviceId(); @@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor { '/auth/refresh', data: {'refresh_token': refresh}, options: Options(headers: { - 'x-tenant-id': AppConfig.tenantSlug, + 'x-tenant-id': BuildConfig.tenantSlug, 'x-app-role': AppConfig.appRole, 'x-device-id': ?deviceId, }), diff --git a/apps/rider_new/lib/core/build_config.dart b/apps/rider_new/lib/core/build_config.dart new file mode 100644 index 0000000..ffa22ba --- /dev/null +++ b/apps/rider_new/lib/core/build_config.dart @@ -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; +} diff --git a/apps/rider_new/lib/core/config/app_config.dart b/apps/rider_new/lib/core/config.dart similarity index 51% rename from apps/rider_new/lib/core/config/app_config.dart rename to apps/rider_new/lib/core/config.dart index 69ee220..480bb0a 100644 --- a/apps/rider_new/lib/core/config/app_config.dart +++ b/apps/rider_new/lib/core/config.dart @@ -1,11 +1,11 @@ -/// إعداد البناء — كل قيمة هنا `const` تُمرَّر عبر `--dart-define` عند البناء. +/// إعداد وقت التشغيل — يُمرَّر بـ`--dart-define` عند البناء (docs/23 §1). /// -/// هذا هو أساس نموذج lite/pro/max (docs/37 §6.25): بناء مولَّد بأعلام ثابتة، -/// لا فروع كود. القيم الافتراضية تشير للسيرفر التجريبي. +/// ما يخصّ **هوية المستأجر البصرية** (لون · لوغو · اسم) يعيش في +/// `build_config.dart` المُولَّد، لا هنا. class AppConfig { const AppConfig._(); - /// أصل الـAPI **مع** `/api` — كما في docs/38. + /// أصل الـAPI **مع** `/api` (docs/38). static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://tripz-api.intaleqapp.com/api', @@ -17,18 +17,19 @@ class AppConfig { defaultValue: 'https://tripz-api.intaleqapp.com', ); - /// **slug** المستأجر لا الـUUID — مصيدة docs/38 §12.9. - static const String tenantSlug = String.fromEnvironment( - 'TENANT_SLUG', - defaultValue: 'siro', - ); - /// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان /// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.** static const String appRole = 'rider'; - /// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار - /// حتى 401 يعني فشل طلب في منتصف رحلة. + /// طول رمز التحقّق كما يولّده الخادم (docs/38 §2). + 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 connectTimeout = Duration(seconds: 20); diff --git a/apps/rider_new/lib/core/design/theme.dart b/apps/rider_new/lib/core/design/theme.dart new file mode 100644 index 0000000..f375f8e --- /dev/null +++ b/apps/rider_new/lib/core/design/theme.dart @@ -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), + ); +} diff --git a/apps/rider_new/lib/core/design/tokens.dart b/apps/rider_new/lib/core/design/tokens.dart new file mode 100644 index 0000000..3ae168d --- /dev/null +++ b/apps/rider_new/lib/core/design/tokens.dart @@ -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; +} diff --git a/apps/rider_new/lib/core/design/tripz_colors.dart b/apps/rider_new/lib/core/design/tripz_colors.dart new file mode 100644 index 0000000..23c9cdb --- /dev/null +++ b/apps/rider_new/lib/core/design/tripz_colors.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2). +/// +/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها، +/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر +/// كل المستأجرين كما يفرض عقد §0. +@immutable +class TripzColors extends ThemeExtension { + 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? 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()!; + ColorScheme get colors => Theme.of(this).colorScheme; + TextTheme get texts => Theme.of(this).textTheme; +} diff --git a/apps/rider_new/lib/core/design/typography.dart b/apps/rider_new/lib/core/design/typography.dart new file mode 100644 index 0000000..cd53abf --- /dev/null +++ b/apps/rider_new/lib/core/design/typography.dart @@ -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 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, + ); diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart new file mode 100644 index 0000000..f042280 --- /dev/null +++ b/apps/rider_new/lib/core/di.dart @@ -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 setupInjector() async { + final prefs = await SharedPreferences.getInstance(); + sl.registerSingleton(prefs); + + const secure = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + final tokens = TokenStore(secure); + await tokens.load(); + await _ensureDeviceId(tokens); + sl.registerSingleton(tokens); + + sl.registerSingleton(SettingsCubit(prefs)); + + // انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر + // يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor. + sl.registerSingleton( + ApiClient.create( + tokens: tokens, + onSessionExpired: () async => sl().onSessionExpired(), + ), + ); + + sl.registerSingleton(AuthRepository(sl(), sl())); + sl.registerSingleton(SessionCubit(sl())); + + // تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية. + sl.registerFactory(() => LoginCubit(sl(), sl())); +} + +/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل +/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل +/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**. +Future _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); +} diff --git a/apps/rider_new/lib/core/di/injector.dart b/apps/rider_new/lib/core/di/injector.dart deleted file mode 100644 index 257480c..0000000 --- a/apps/rider_new/lib/core/di/injector.dart +++ /dev/null @@ -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 setupInjector() async { - sl.registerSingleton( - await SharedPreferences.getInstance(), - ); - - const secure = FlutterSecureStorage( - iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), - ); - - final tokens = TokenStore(secure); - await tokens.load(); - sl.registerSingleton(tokens); - - sl.registerSingleton( - ApiClient.create( - tokens: tokens, - // يُربط بالتوجيه في م3 (إخراج للمستخدم عند انتهاء الجلسة). - onSessionExpired: () async {}, - ), - ); -} diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb new file mode 100644 index 0000000..88d7445 --- /dev/null +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -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" +} diff --git a/apps/rider_new/lib/core/l10n/app_en.arb b/apps/rider_new/lib/core/l10n/app_en.arb new file mode 100644 index 0000000..79404c2 --- /dev/null +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -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" +} diff --git a/apps/rider_new/lib/core/l10n/l10n.dart b/apps/rider_new/lib/core/l10n/l10n.dart new file mode 100644 index 0000000..abd1e2c --- /dev/null +++ b/apps/rider_new/lib/core/l10n/l10n.dart @@ -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); +} diff --git a/apps/rider_new/lib/core/router.dart b/apps/rider_new/lib/core/router.dart new file mode 100644 index 0000000..8284422 --- /dev/null +++ b/apps/rider_new/lib/core/router.dart @@ -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().stream), + redirect: (context, state) { + final session = sl().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 stream) { + _sub = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _sub; + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } +} diff --git a/apps/rider_new/lib/core/session/app_user.dart b/apps/rider_new/lib/core/session/app_user.dart new file mode 100644 index 0000000..d6aac53 --- /dev/null +++ b/apps/rider_new/lib/core/session/app_user.dart @@ -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 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 get props => [id, tenantId, phone, role, status, language, name]; +} diff --git a/apps/rider_new/lib/core/session/session_cubit.dart b/apps/rider_new/lib/core/session/session_cubit.dart new file mode 100644 index 0000000..51ef5a5 --- /dev/null +++ b/apps/rider_new/lib/core/session/session_cubit.dart @@ -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 { + SessionCubit(this._repo) : super(const SessionState()); + + final SessionSource _repo; + + /// تُستدعى مرة عند الإقلاع من شاشة الـsplash. + Future 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 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, + )); + } +} diff --git a/apps/rider_new/lib/core/session/session_source.dart b/apps/rider_new/lib/core/session/session_source.dart new file mode 100644 index 0000000..8366f5c --- /dev/null +++ b/apps/rider_new/lib/core/session/session_source.dart @@ -0,0 +1,13 @@ +import 'app_user.dart'; + +/// ما تحتاجه الجلسة من طبقة المصادقة — **ولا شيء أكثر**. +/// +/// `SessionCubit` حالةٌ على مستوى التطبيق (الراوتر والشاشات كلها تقرأها)، +/// فمحلّها `core/`. ولأن `core/` لا يجوز أن يستورد من `features/` كما لا +/// تستورد ميزةٌ من أخرى (docs/23 §1)، تُعرّف الحاجة هنا كواجهة ضيّقة +/// ويُنفّذها `AuthRepository` في ميزة المصادقة. +abstract interface class SessionSource { + Future me(); + Future logout(); + bool get hasSession; +} diff --git a/apps/rider_new/lib/core/session/session_state.dart b/apps/rider_new/lib/core/session/session_state.dart new file mode 100644 index 0000000..a6567f3 --- /dev/null +++ b/apps/rider_new/lib/core/session/session_state.dart @@ -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 get props => [status, user]; +} diff --git a/apps/rider_new/lib/core/theme/app_theme.dart b/apps/rider_new/lib/core/theme/app_theme.dart deleted file mode 100644 index e082d46..0000000 --- a/apps/rider_new/lib/core/theme/app_theme.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/apps/rider_new/lib/core/ui/empty_view.dart b/apps/rider_new/lib/core/ui/empty_view.dart new file mode 100644 index 0000000..bae9cdb --- /dev/null +++ b/apps/rider_new/lib/core/ui/empty_view.dart @@ -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), + ], + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/error_view.dart b/apps/rider_new/lib/core/ui/error_view.dart new file mode 100644 index 0000000..0e0eb7c --- /dev/null +++ b/apps/rider_new/lib/core/ui/error_view.dart @@ -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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/skeleton.dart b/apps/rider_new/lib/core/ui/skeleton.dart new file mode 100644 index 0000000..9c5fddb --- /dev/null +++ b/apps/rider_new/lib/core/ui/skeleton.dart @@ -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 createState() => _SkeletonState(); +} + +class _SkeletonState extends State + 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), + ), + ); +} diff --git a/apps/rider_new/lib/core/ui/status_banner.dart b/apps/rider_new/lib/core/ui/status_banner.dart new file mode 100644 index 0000000..b2e3c9d --- /dev/null +++ b/apps/rider_new/lib/core/ui/status_banner.dart @@ -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, + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_button.dart b/apps/rider_new/lib/core/ui/tripz_button.dart new file mode 100644 index 0000000..76e4bf2 --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_button.dart @@ -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), + ], + ); + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_card.dart b/apps/rider_new/lib/core/ui/tripz_card.dart new file mode 100644 index 0000000..9bf35ca --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_card.dart @@ -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), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_otp_field.dart b/apps/rider_new/lib/core/ui/tripz_otp_field.dart new file mode 100644 index 0000000..0779f88 --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_otp_field.dart @@ -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? onCompleted; + final bool enabled; + final bool hasError; + + @override + State createState() => _TripzOtpFieldState(); +} + +class _TripzOtpFieldState extends State { + 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( + 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), + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_scaffold.dart b/apps/rider_new/lib/core/ui/tripz_scaffold.dart new file mode 100644 index 0000000..8e5fdcf --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_scaffold.dart @@ -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? 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, + }; + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_sheet.dart b/apps/rider_new/lib/core/ui/tripz_sheet.dart new file mode 100644 index 0000000..331e8bb --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_sheet.dart @@ -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 show( + BuildContext context, { + required Widget child, + String? title, + Widget? bottomAction, + bool dismissible = true, + }) { + return showModalBottomSheet( + 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!, + ], + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/tripz_text_field.dart b/apps/rider_new/lib/core/ui/tripz_text_field.dart new file mode 100644 index 0000000..b0e0acb --- /dev/null +++ b/apps/rider_new/lib/core/ui/tripz_text_field.dart @@ -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? onChanged, + ValueChanged? 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? onChanged; + final ValueChanged? onSubmitted; + final bool autofocus; + final bool enabled; + final int? maxLength; + final IconData? prefixIcon; + final Widget? suffix; + final List? inputFormatters; + final Iterable? 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, + ), + ); + } +} diff --git a/apps/rider_new/lib/core/ui/view_status.dart b/apps/rider_new/lib/core/ui/view_status.dart new file mode 100644 index 0000000..d94443c --- /dev/null +++ b/apps/rider_new/lib/core/ui/view_status.dart @@ -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; +} diff --git a/apps/rider_new/lib/features/auth/cubit/auth_failure.dart b/apps/rider_new/lib/features/auth/cubit/auth_failure.dart new file mode 100644 index 0000000..2efe08f --- /dev/null +++ b/apps/rider_new/lib/features/auth/cubit/auth_failure.dart @@ -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; + } +} diff --git a/apps/rider_new/lib/features/auth/cubit/login_cubit.dart b/apps/rider_new/lib/features/auth/cubit/login_cubit.dart new file mode 100644 index 0000000..389a2d4 --- /dev/null +++ b/apps/rider_new/lib/features/auth/cubit/login_cubit.dart @@ -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 { + 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 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 acceptAgreement() async { + if (!state.agreed) return; + await _prefs.setBool(_kAgreed, true); + emit(state.copyWith(step: LoginStep.permission)); + await checkLocationPermission(); + } + + /// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى + /// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2). + Future 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 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 openSystemSettings() => openAppSettings(); + + // ── الهاتف ───────────────────────────────────────────────────────────── + + void phoneChanged(String value) { + emit(state.copyWith( + phone: value, + clearPhoneError: true, + clearFailure: true, + )); + } + + Future 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 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 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)); + }); + } +} diff --git a/apps/rider_new/lib/features/auth/cubit/login_state.dart b/apps/rider_new/lib/features/auth/cubit/login_state.dart new file mode 100644 index 0000000..9621f23 --- /dev/null +++ b/apps/rider_new/lib/features/auth/cubit/login_state.dart @@ -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 get props => [ + step, + status, + phone, + agreed, + failure, + phoneError, + resendIn, + permissionPermanentlyDenied, + user, + ]; +} diff --git a/apps/rider_new/lib/features/auth/cubit/phone_error.dart b/apps/rider_new/lib/features/auth/cubit/phone_error.dart new file mode 100644 index 0000000..ad8ac94 --- /dev/null +++ b/apps/rider_new/lib/features/auth/cubit/phone_error.dart @@ -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; +} diff --git a/apps/rider_new/lib/features/auth/cubit/profile_cubit.dart b/apps/rider_new/lib/features/auth/cubit/profile_cubit.dart new file mode 100644 index 0000000..6e16708 --- /dev/null +++ b/apps/rider_new/lib/features/auth/cubit/profile_cubit.dart @@ -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 { + ProfileCubit(this._repo) : super(const ProfileState()); + + final AuthRepository _repo; + + Future 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; + } + } +} diff --git a/apps/rider_new/lib/features/auth/data/auth_repository.dart b/apps/rider_new/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..5af7e4f --- /dev/null +++ b/apps/rider_new/lib/features/auth/data/auth_repository.dart @@ -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 sendOtp(String phone) async { + await _api.post>( + '/auth/send-otp', + body: {'phone': phone}, + ); + } + + /// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها. + Future verifyOtp({ + required String phone, + required String code, + String? referralCode, + }) async { + final res = await _api.post>( + '/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); + } + + @override + Future me() async { + final res = await _api.get>('/users/me'); + return AppUser.fromJson(res); + } + + Future updateProfile({String? name, String? language}) async { + final res = await _api.patch>( + '/users/me', + body: { + 'name': ?name, + 'language': ?language, + }, + ); + return AppUser.fromJson(res); + } + + @override + Future logout() => _tokens.clear(); + + @override + bool get hasSession => _tokens.isLoggedIn; +} diff --git a/apps/rider_new/lib/features/auth/view/agreement_view.dart b/apps/rider_new/lib/features/auth/view/agreement_view.dart new file mode 100644 index 0000000..1f31675 --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/agreement_view.dart @@ -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( + buildWhen: (p, n) => p.agreed != n.agreed, + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/login_page.dart b/apps/rider_new/lib/features/auth/view/login_page.dart new file mode 100644 index 0000000..64c4c38 --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends State with WidgetsBindingObserver { + late final LoginCubit _cubit = sl(); + + @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( + listenWhen: (prev, next) => next.step == LoginStep.done, + listener: (context, state) { + final user = state.user; + if (user != null) context.read().onLoggedIn(user); + }, + child: BlocBuilder( + 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(), + }, + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/otp_view.dart b/apps/rider_new/lib/features/auth/view/otp_view.dart new file mode 100644 index 0000000..bc10912 --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/otp_view.dart @@ -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 createState() => _OtpViewState(); +} + +class _OtpViewState extends State { + final _code = TextEditingController(); + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/permission_view.dart b/apps/rider_new/lib/features/auth/view/permission_view.dart new file mode 100644 index 0000000..f8f4b2e --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/permission_view.dart @@ -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( + buildWhen: (p, n) => + p.permissionPermanentlyDenied != n.permissionPermanentlyDenied, + builder: (context, state) { + final cubit = context.read(); + 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, + ), + ], + ], + ), + ); + }, + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/phone_view.dart b/apps/rider_new/lib/features/auth/view/phone_view.dart new file mode 100644 index 0000000..8a7e7dd --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/phone_view.dart @@ -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( + buildWhen: (p, n) => + p.status != n.status || + p.phoneError != n.phoneError || + p.failure != n.failure, + builder: (context, state) { + final cubit = context.read(); + 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!), + ], + ), + ); + }, + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/profile_page.dart b/apps/rider_new/lib/features/auth/view/profile_page.dart new file mode 100644 index 0000000..9664276 --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/profile_page.dart @@ -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 createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + 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( + 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 _submit(BuildContext context) async { + if (_name.text.trim().isEmpty) { + setState(() => _error = context.l10n.profileNameErrEmpty); + return; + } + final session = context.read(); + final user = await context.read().save(_name.text); + if (user != null) session.onProfileCompleted(user); + } +} diff --git a/apps/rider_new/lib/features/auth/view/widgets/auth_failure_text.dart b/apps/rider_new/lib/features/auth/view/widgets/auth_failure_text.dart new file mode 100644 index 0000000..6936ea3 --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/widgets/auth_failure_text.dart @@ -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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/features/auth/view/widgets/auth_header.dart b/apps/rider_new/lib/features/auth/view/widgets/auth_header.dart new file mode 100644 index 0000000..1de0d2d --- /dev/null +++ b/apps/rider_new/lib/features/auth/view/widgets/auth_header.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/home/view/home_page.dart b/apps/rider_new/lib/features/home/view/home_page.dart new file mode 100644 index 0000000..04a9fac --- /dev/null +++ b/apps/rider_new/lib/features/home/view/home_page.dart @@ -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( + 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().logout, + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/rider_new/lib/features/settings/cubit/settings_cubit.dart b/apps/rider_new/lib/features/settings/cubit/settings_cubit.dart new file mode 100644 index 0000000..4875dff --- /dev/null +++ b/apps/rider_new/lib/features/settings/cubit/settings_cubit.dart @@ -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 { + 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 setThemeMode(ThemeMode mode) async { + emit(state.copyWith(themeMode: mode)); + await _prefs.setString(_kTheme, mode.name); + } + + Future setLocale(Locale locale) async { + emit(state.copyWith(locale: locale)); + await _prefs.setString(_kLocale, locale.languageCode); + } +} diff --git a/apps/rider_new/lib/features/settings/cubit/settings_state.dart b/apps/rider_new/lib/features/settings/cubit/settings_state.dart new file mode 100644 index 0000000..f841895 --- /dev/null +++ b/apps/rider_new/lib/features/settings/cubit/settings_state.dart @@ -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 get props => [themeMode, locale]; +} diff --git a/apps/rider_new/lib/features/splash/splash_page.dart b/apps/rider_new/lib/features/splash/splash_page.dart deleted file mode 100644 index 9854908..0000000 --- a/apps/rider_new/lib/features/splash/splash_page.dart +++ /dev/null @@ -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), - ), - ], - ), - ), - ); - } -} diff --git a/apps/rider_new/lib/features/splash/view/splash_page.dart b/apps/rider_new/lib/features/splash/view/splash_page.dart new file mode 100644 index 0000000..c8e5341 --- /dev/null +++ b/apps/rider_new/lib/features/splash/view/splash_page.dart @@ -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 createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + // بعد أول إطار: `restore` قد يُصدر حالة فوراً، وإصدارها أثناء البناء خطأ. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().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), + ), + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/main.dart b/apps/rider_new/lib/main.dart index b5a5ff8..955cded 100644 --- a/apps/rider_new/lib/main.dart +++ b/apps/rider_new/lib/main.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import 'app/app.dart'; -import 'core/di/injector.dart'; +import 'app.dart'; +import 'core/di.dart'; +import 'core/session/session_cubit.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await setupInjector(); - runApp(const TripzApp()); + runApp( + // الجلسة فوق الجميع: الراوتر والشاشات يقرآنها من نقطة واحدة. + BlocProvider.value(value: sl(), child: const TripzApp()), + ); } diff --git a/apps/rider_new/pubspec.lock b/apps/rider_new/pubspec.lock index f21bfbd..9eb5b8c 100644 --- a/apps/rider_new/pubspec.lock +++ b/apps/rider_new/pubspec.lock @@ -81,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -121,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -207,10 +215,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -437,6 +445,54 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -622,10 +678,18 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" 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: dependency: transitive description: diff --git a/apps/rider_new/pubspec.yaml b/apps/rider_new/pubspec.yaml index 8b4920f..7d2af6f 100644 --- a/apps/rider_new/pubspec.yaml +++ b/apps/rider_new/pubspec.yaml @@ -29,10 +29,14 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + # م3: الأذونات وبصمة الجهاز + permission_handler: ^12.0.1 + device_info_plus: ^12.3.0 + cupertino_icons: ^1.0.8 # باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7): -# م3: device_info_plus · crypto (توقيع الطلبات وبصمة الجهاز) +# م3 لاحقاً: crypto (توقيع طلبات النقاط المالية) # م4: الخريطة · geolocator · socket_io_client # م5: التقييم والمحفظة م6: الإشعارات والدردشة @@ -44,6 +48,9 @@ dev_dependencies: flutter: uses-material-design: true + # توليد الترجمة من ARB (docs/26 §4) + generate: true + assets: - assets/ - assets/images/ diff --git a/docs/37-newapps-cubit-plan.md b/docs/37-newapps-cubit-plan.md index 7af711f..53d7632 100644 --- a/docs/37-newapps-cubit-plan.md +++ b/docs/37-newapps-cubit-plan.md @@ -143,7 +143,28 @@ 9. **بوابة**: يبني على السيرفر، يقلع على المحاكي بشاشة splash، و`flutter analyze` نظيف. -### المرحلة 2 — نظام التصميم (هنا يُكسب الشكل أو يُخسر) +### المرحلة 2 — نظام التصميم — **نُفّذت 2026-08-04** + +مُنجز: `core/design/` (tokens · typography · TripzColors · buildTheme) · +`core/ui/` عدّة من 11 مكوّناً · `core/l10n/` ARB عربي/إنجليزي مع `gen_l10n` · +`SettingsCubit` (المظهر واللغة محفوظان محلياً). + +**تصحيح بنيوي**: بنية م1 كانت تخالف `docs/23 §1` المُلزِم (`lib/app/` و +`core/config/` و`core/network/`). أُعيدت للشجرة المفروضة حرفياً: +`app.dart` · `core/{config,build_config,di,router}.dart` · +`core/{design,ui,l10n,api,storage,session}/` · `features//{cubit,data,view}/`. + +**قراران يستحقّان المراجعة:** +1. **لا `packages/tripz_ui`.** خطتي الأصلية (م2.11) اقترحت حزمة مشتركة، لكن + `docs/23 §1` و`docs/26 §10` كلاهما مُلزِم ويفرض `lib/core/design` و + `lib/core/ui` **داخل** كل تطبيق. اتُّبعت الوثيقتان، والملفّان متطابقان + حرفياً بين التطبيقين (يُزامنان بـ`rsync` وأي تباعد يُكتشف بـ`diff -rq`). +2. **رمز الفشل لا نصّه.** `docs/23 §3` يقول إن الـCubit يبني الرسالة العربية، + و`docs/26 §4` يوجب مرور كل نص بالترجمة — والـCubit لا يعرف `BuildContext`. + الحل: الـCubit يُصدر `AuthFailure` كرمز، وتترجمه الواجهة في ملف واحد + (`auth_failure_text.dart`). الشاشة تبقى بلا منطق، والنص بلغتين. + +### (الأصل المعتمد للمرحلة 2) 10. تطبيق `docs/26-flutter-design-system.md` + `docs/23-flutter-conventions.md`: theme · ألوان · تايبوغرافي · مسافات · حركة · RTL/LTR بأربع لغات. 11. حزمة مشتركة `packages/tripz_ui` يستهلكها التطبيقان (تجنّب ازدواج الشاشات). @@ -152,7 +173,25 @@ قوائم كسولة · بلا عمل ثقيل في `build`. سبب الأداء السيئ سابقاً يُشخّص من `*-archive-cubit` **مرة واحدة** ويُكتب هنا كقائمة ممنوعات. -### المرحلة 3 — التسجيل والدخول (OTP) كاملاً +### المرحلة 3 — التسجيل والدخول — **نُفّذت 2026-08-04، البوابة معلّقة** + +مُنجز: طبقة الشبكة (Dio + تجديد استباقي بطلقة واحدة + `x-app-role` + +`x-device-id` من `device_info_plus`) · `AuthRepository` · `SessionCubit` في +`core/session/` خلف واجهة `SessionSource` (كي لا يستورد `core` من `features`) · +`LoginCubit` بخطواته الخمس · `ProfileCubit` · ست شاشات: +الشروط ← إذن الموقع ← الهاتف ← الرمز ← إكمال الملف ← هيكل الرئيسية. + +نُقلت من سيرو كسلوك لا ككود: بوابتان **قبل** أي حقل إدخال · إعادة فحص الإذن +عند العودة من إعدادات النظام (`didChangeAppLifecycleState`) · تحقّق الهاتف +الثلاثي (فارغ/صفر بادئ/قصير). +أُضيف ما ينقص سيرو: **عدّاد إعادة الإرسال** — بدونه تُحظر ثلاث ضغطات المستخدمَ +خمس دقائق (`docs/38 §2`). + +`flutter analyze` نظيف في التطبيقين · 48 ملف · 3,140 سطر لكل تطبيق · +الفرق بينهما **أربعة ملفات فقط** (`build_config` · `config` · ملفّا ARB). +البوابة (دخول حقيقي على الخادم) تنتظر التشغيل. + +### (الأصل المعتمد للمرحلة 3) 13. طبقة الشبكة: Dio + interceptors + تجديد التوكن + `x-app-role`. 14. شاشات: onboarding → إدخال الهاتف → OTP → إكمال الملف → استعادة الجلسة. 15. مراعاة قرار **هوية الراكب/السائق المنفصلة** (قيد `tenant,phone_bidx,role`