feat(apps): نظام التصميم والمصادقة الكاملة — المرحلتان 2 و3
## تصحيح بنيوي أولاً
بنية المرحلة 1 كانت تخالف docs/23 §1 المُلزِم. أُعيدت للشجرة المفروضة
حرفياً: app.dart في الجذر · core/{config,build_config,di,router}.dart ·
core/{design,ui,l10n,api,storage,session}/ · features/<f>/{cubit,data,view}/
## المرحلة 2 — نظام التصميم (docs/26)
- core/design: tokens (مسافات/زوايا/حركة/أحجام — الشاشة لا تخترع رقماً) ·
typography (IBM Plex Sans Arabic + Inter محليّان، بأرقام tabular للأسعار
والعدّادات) · TripzColors كـThemeExtension للأدوار الدلالية ·
buildTheme(brightness, locale) بمدخلين لا ثالث لهما
- core/ui: 11 مكوّناً. TripzScaffold يحوّل status الـCubit وحده إلى
skeleton/خطأ/فراغ/محتوى — فلا تكتب أي شاشة if (loading)
- core/l10n: ARB عربي/إنجليزي + gen_l10n. لا نص مرئي داخل widget
- SettingsCubit: المظهر واللغة. الاتجاه يتبع اللغة آلياً بلا Directionality
مفروضة، وسقف تكبير النص 1.3
## المرحلة 3 — المصادقة
- طبقة الشبكة: تجديد استباقي بطلقة واحدة (عمر التوكن 15 دقيقة) ·
x-app-role · x-device-id من device_info_plus — يُرسَل الآن كي يُفعَّل علم
الخادم لاحقاً بلا تعديل التطبيق
- SessionCubit في core/session خلف واجهة SessionSource: الجلسة حالة على
مستوى التطبيق لا ميزة، و core لا يستورد من features
- LoginCubit بخطواته الخمس، ProfileCubit، وست شاشات:
الشروط ← إذن الموقع ← الهاتف ← الرمز ← إكمال الملف ← هيكل الرئيسية
من سيرو نُقل السلوك لا الكود: بوابتان قبل أي حقل إدخال · إعادة فحص الإذن عند
العودة من إعدادات النظام · تحقّق الهاتف الثلاثي. وأُضيف ما ينقصه: عدّاد إعادة
إرسال الرمز — بدونه تُحظر ثلاث ضغطات المستخدمَ خمس دقائق (docs/38 §2).
قراران موثّقان في docs/37: لا packages/tripz_ui (الوثيقتان المُلزِمتان تفرضان
core/design و core/ui داخل التطبيق)، والـCubit يُصدر رمز فشل لا نصّاً
(يجمع بين docs/23 §3 و docs/26 §4).
flutter analyze نظيف في التطبيقين · 48 ملف و3,140 سطر لكل تطبيق · الفرق
بينهما أربعة ملفات فقط: build_config · config · ملفّا ARB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0de8573d27
commit
c52338f3bb
@@ -43,3 +43,6 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# مُولَّد من ARB — لا يُحرَّر
|
||||
lib/core/l10n/gen/
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import 'core/build_config.dart';
|
||||
import 'core/design/theme.dart';
|
||||
import 'core/di.dart';
|
||||
import 'core/l10n/l10n.dart';
|
||||
import 'core/router.dart';
|
||||
import 'features/settings/cubit/settings_cubit.dart';
|
||||
import 'features/settings/cubit/settings_state.dart';
|
||||
|
||||
class TripzApp extends StatelessWidget {
|
||||
const TripzApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => sl<SettingsCubit>(),
|
||||
child: BlocBuilder<SettingsCubit, SettingsState>(
|
||||
builder: (context, settings) {
|
||||
return MaterialApp.router(
|
||||
title: BuildConfig.appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: appRouter,
|
||||
|
||||
// الثيم يُبنى من مدخلين لا ثالث لهما (docs/26 §4).
|
||||
theme: buildTheme(Brightness.light, settings.locale),
|
||||
darkTheme: buildTheme(Brightness.dark, settings.locale),
|
||||
themeMode: settings.themeMode,
|
||||
|
||||
// الاتجاه يتبع اللغة آلياً — لا `Directionality` مفروضة فوق كل
|
||||
// شيء، وإلا انكسرت الإنجليزية (docs/26 §4).
|
||||
locale: settings.locale,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
...L10n.localizationsDelegates,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
|
||||
// سقف تكبير النص 1.3 — وهو ما تُفحص عليه الشاشات (docs/26 §9).
|
||||
// ما فوقه يكسر التخطيط بلا مكسب قرائي.
|
||||
builder: (context, child) => MediaQuery.withClampedTextScaling(
|
||||
maxScaleFactor: 1.3,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/splash/splash_page.dart';
|
||||
|
||||
/// جدول التوجيه — يمتدّ في م3 (المصادقة) وم4 (الخريطة).
|
||||
class AppRoutes {
|
||||
const AppRoutes._();
|
||||
static const splash = '/';
|
||||
}
|
||||
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: AppRoutes.splash,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
+1
-1
@@ -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';
|
||||
+4
-3
@@ -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,
|
||||
}),
|
||||
@@ -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;
|
||||
}
|
||||
+13
-12
@@ -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);
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../build_config.dart';
|
||||
import 'tokens.dart';
|
||||
import 'tripz_colors.dart';
|
||||
import 'typography.dart';
|
||||
|
||||
/// دالة بناء الثيم الوحيدة (docs/26 §4): مدخلان لا ثالث لهما.
|
||||
///
|
||||
/// الاتجاه (RTL/LTR) **لا يُفرض هنا** — `MaterialApp` يشتقّه من الـlocale
|
||||
/// وحده. فرض `Directionality.rtl` يكسر الإنجليزية (docs/26 §4).
|
||||
ThemeData buildTheme(Brightness brightness, Locale locale) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: BuildConfig.seedColor,
|
||||
brightness: brightness,
|
||||
);
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final texts = buildTextTheme(brightness, locale);
|
||||
final tripz = isDark ? TripzColors.dark : TripzColors.light;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
brightness: brightness,
|
||||
fontFamily: AppFonts.familyFor(locale),
|
||||
textTheme: texts,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
extensions: [tripz],
|
||||
|
||||
// AppBar مسطّح بلا ظل (docs/26 §6).
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: texts.titleLarge?.copyWith(color: scheme.onSurface),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: tripz.surfaceRaised,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: Radii.card_,
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: Radii.sheetTop),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: isDark ? scheme.surfaceContainerHigh : scheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.md,
|
||||
vertical: Space.sm,
|
||||
),
|
||||
border: _fieldBorder(scheme.outlineVariant),
|
||||
enabledBorder: _fieldBorder(scheme.outlineVariant),
|
||||
focusedBorder: _fieldBorder(scheme.primary, width: 1.5),
|
||||
errorBorder: _fieldBorder(scheme.error),
|
||||
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
|
||||
errorStyle: texts.bodySmall?.copyWith(color: scheme.error),
|
||||
),
|
||||
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
contentTextStyle:
|
||||
texts.bodyMedium?.copyWith(color: scheme.onInverseSurface),
|
||||
shape: const RoundedRectangleBorder(borderRadius: Radii.card_),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) {
|
||||
return OutlineInputBorder(
|
||||
borderRadius: Radii.field_,
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// الرموز الثابتة (docs/26 §3). **الشاشة لا تخترع رقماً** — لا مسافة ولا زاوية
|
||||
/// ولا مدّة خارج هذا الملف.
|
||||
class Space {
|
||||
const Space._();
|
||||
|
||||
/// سلّم مضاعفات الأربعة.
|
||||
static const double xxs = 4;
|
||||
static const double xs = 8;
|
||||
static const double sm = 12;
|
||||
|
||||
/// حشوة الصفحة الافتراضية.
|
||||
static const double md = 16;
|
||||
static const double lg = 24;
|
||||
static const double xl = 32;
|
||||
static const double xxl = 48;
|
||||
|
||||
static const EdgeInsets page = EdgeInsets.all(md);
|
||||
static const EdgeInsets pageH = EdgeInsets.symmetric(horizontal: md);
|
||||
}
|
||||
|
||||
class Radii {
|
||||
const Radii._();
|
||||
|
||||
/// حقول وشارات.
|
||||
static const double field = 8;
|
||||
|
||||
/// أزرار وبطاقات.
|
||||
static const double card = 12;
|
||||
|
||||
/// الأوراق السفلية.
|
||||
static const double sheet = 24;
|
||||
|
||||
static const BorderRadius field_ = BorderRadius.all(Radius.circular(field));
|
||||
static const BorderRadius card_ = BorderRadius.all(Radius.circular(card));
|
||||
static const BorderRadius sheetTop = BorderRadius.vertical(
|
||||
top: Radius.circular(sheet),
|
||||
);
|
||||
}
|
||||
|
||||
class Motion {
|
||||
const Motion._();
|
||||
|
||||
/// استجابة لمسة.
|
||||
static const Duration tap = Duration(milliseconds: 120);
|
||||
|
||||
/// انتقال ضمن الشاشة.
|
||||
static const Duration inScreen = Duration(milliseconds: 240);
|
||||
|
||||
/// ورقة سفلية أو صفحة.
|
||||
static const Duration page = Duration(milliseconds: 400);
|
||||
|
||||
static const Curve curve = Curves.easeOutCubic;
|
||||
}
|
||||
|
||||
class Sizes {
|
||||
const Sizes._();
|
||||
|
||||
static const double iconSm = 20;
|
||||
static const double icon = 24;
|
||||
static const double iconLg = 32;
|
||||
|
||||
/// ارتفاع زر الـCTA وحقل الإدخال.
|
||||
static const double control = 52;
|
||||
|
||||
/// أدنى هدف لمس مقبول.
|
||||
static const double touchTarget = 48;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2).
|
||||
///
|
||||
/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها،
|
||||
/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر
|
||||
/// كل المستأجرين كما يفرض عقد §0.
|
||||
@immutable
|
||||
class TripzColors extends ThemeExtension<TripzColors> {
|
||||
const TripzColors({
|
||||
required this.success,
|
||||
required this.onSuccess,
|
||||
required this.warning,
|
||||
required this.onWarning,
|
||||
required this.danger,
|
||||
required this.onDanger,
|
||||
required this.info,
|
||||
required this.surfaceRaised,
|
||||
required this.mapRoute,
|
||||
required this.mapOrigin,
|
||||
required this.mapDestination,
|
||||
required this.skeleton,
|
||||
});
|
||||
|
||||
final Color success;
|
||||
final Color onSuccess;
|
||||
final Color warning;
|
||||
final Color onWarning;
|
||||
final Color danger;
|
||||
final Color onDanger;
|
||||
final Color info;
|
||||
|
||||
/// السطح المرتفع — في الليلي ارتفاعٌ **بالسطوع لا بالظل** (docs/26 §2).
|
||||
final Color surfaceRaised;
|
||||
|
||||
final Color mapRoute;
|
||||
final Color mapOrigin;
|
||||
final Color mapDestination;
|
||||
|
||||
/// لون هيكل التحميل (skeleton) — لا spinner فارغ (docs/26 §8.4).
|
||||
final Color skeleton;
|
||||
|
||||
static const light = TripzColors(
|
||||
success: Color(0xFF1B8A5A),
|
||||
onSuccess: Color(0xFFFFFFFF),
|
||||
warning: Color(0xFFB4690E),
|
||||
onWarning: Color(0xFFFFFFFF),
|
||||
danger: Color(0xFFC5303B),
|
||||
onDanger: Color(0xFFFFFFFF),
|
||||
info: Color(0xFF2563A8),
|
||||
surfaceRaised: Color(0xFFFFFFFF),
|
||||
mapRoute: Color(0xFF1F6FEB),
|
||||
mapOrigin: Color(0xFF1B8A5A),
|
||||
mapDestination: Color(0xFFC5303B),
|
||||
skeleton: Color(0xFFE6E8EC),
|
||||
);
|
||||
|
||||
static const dark = TripzColors(
|
||||
success: Color(0xFF4ECB8D),
|
||||
onSuccess: Color(0xFF00281A),
|
||||
warning: Color(0xFFE9A63B),
|
||||
onWarning: Color(0xFF2A1A00),
|
||||
danger: Color(0xFFF2707A),
|
||||
onDanger: Color(0xFF33000A),
|
||||
info: Color(0xFF7FB2F0),
|
||||
// ليس أسود صرفاً — رمادي داكن متدرّج.
|
||||
surfaceRaised: Color(0xFF20232A),
|
||||
mapRoute: Color(0xFF5B9BFF),
|
||||
mapOrigin: Color(0xFF4ECB8D),
|
||||
mapDestination: Color(0xFFF2707A),
|
||||
skeleton: Color(0xFF2C3038),
|
||||
);
|
||||
|
||||
@override
|
||||
TripzColors copyWith({
|
||||
Color? success,
|
||||
Color? onSuccess,
|
||||
Color? warning,
|
||||
Color? onWarning,
|
||||
Color? danger,
|
||||
Color? onDanger,
|
||||
Color? info,
|
||||
Color? surfaceRaised,
|
||||
Color? mapRoute,
|
||||
Color? mapOrigin,
|
||||
Color? mapDestination,
|
||||
Color? skeleton,
|
||||
}) {
|
||||
return TripzColors(
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
warning: warning ?? this.warning,
|
||||
onWarning: onWarning ?? this.onWarning,
|
||||
danger: danger ?? this.danger,
|
||||
onDanger: onDanger ?? this.onDanger,
|
||||
info: info ?? this.info,
|
||||
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
|
||||
mapRoute: mapRoute ?? this.mapRoute,
|
||||
mapOrigin: mapOrigin ?? this.mapOrigin,
|
||||
mapDestination: mapDestination ?? this.mapDestination,
|
||||
skeleton: skeleton ?? this.skeleton,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TripzColors lerp(ThemeExtension<TripzColors>? other, double t) {
|
||||
if (other is! TripzColors) return this;
|
||||
return TripzColors(
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
|
||||
warning: Color.lerp(warning, other.warning, t)!,
|
||||
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
|
||||
danger: Color.lerp(danger, other.danger, t)!,
|
||||
onDanger: Color.lerp(onDanger, other.onDanger, t)!,
|
||||
info: Color.lerp(info, other.info, t)!,
|
||||
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
|
||||
mapRoute: Color.lerp(mapRoute, other.mapRoute, t)!,
|
||||
mapOrigin: Color.lerp(mapOrigin, other.mapOrigin, t)!,
|
||||
mapDestination: Color.lerp(mapDestination, other.mapDestination, t)!,
|
||||
skeleton: Color.lerp(skeleton, other.skeleton, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension TripzColorsX on BuildContext {
|
||||
TripzColors get tripzColors => Theme.of(this).extension<TripzColors>()!;
|
||||
ColorScheme get colors => Theme.of(this).colorScheme;
|
||||
TextTheme get texts => Theme.of(this).textTheme;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الخطوط (docs/26 §1) — ملفات محلية في `assets/fonts/`. حزمة `google_fonts`
|
||||
/// ممنوعة: الشكل يجب أن يكون حتمياً من أول إقلاع بلا شبكة.
|
||||
class AppFonts {
|
||||
const AppFonts._();
|
||||
|
||||
static const arabic = 'IBMPlexSansArabic';
|
||||
static const latin = 'Inter';
|
||||
|
||||
/// الخط يتبع اللغة، والآخر احتياط — كي لا تنكسر كلمة عربية داخل جملة
|
||||
/// إنجليزية ولا العكس.
|
||||
static String familyFor(Locale locale) =>
|
||||
locale.languageCode == 'ar' ? arabic : latin;
|
||||
|
||||
static List<String> fallbackFor(Locale locale) =>
|
||||
locale.languageCode == 'ar' ? const [latin] : const [arabic];
|
||||
}
|
||||
|
||||
/// أرقام tabular: تمنع «رقص» الأعمدة حين تتغيّر القيمة — للأسعار والعدّادات
|
||||
/// والمسافات الحيّة (docs/26 §1). يُغني عن خط `digit` المتقاعد.
|
||||
const tabularFigures = [FontFeature.tabularFigures()];
|
||||
|
||||
TextTheme buildTextTheme(Brightness brightness, Locale locale) {
|
||||
final base = brightness == Brightness.dark
|
||||
? Typography.material2021().white
|
||||
: Typography.material2021().black;
|
||||
|
||||
final family = AppFonts.familyFor(locale);
|
||||
final fallback = AppFonts.fallbackFor(locale);
|
||||
|
||||
return base
|
||||
.apply(fontFamily: family, fontFamilyFallback: fallback)
|
||||
.copyWith(
|
||||
displaySmall: base.displaySmall?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
headlineMedium: base.headlineMedium?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
headlineSmall: base.headlineSmall?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleLarge: base.titleLarge?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleMedium: base.titleMedium?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
labelLarge: base.labelLarge?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// نمط الأرقام الحيّة — يُستعمل للأجرة والرصيد والعدّاد التنازلي.
|
||||
/// دائماً Inter بأرقام tabular، مهما كانت لغة الواجهة (docs/26 §5: الأرقام
|
||||
/// لاتينية في اللغتين).
|
||||
TextStyle numericStyle(TextStyle? base) => (base ?? const TextStyle()).copyWith(
|
||||
fontFamily: AppFonts.latin,
|
||||
fontFeatures: tabularFigures,
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../features/auth/cubit/login_cubit.dart';
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
import '../features/settings/cubit/settings_cubit.dart';
|
||||
import 'api/api_client.dart';
|
||||
import 'session/session_cubit.dart';
|
||||
import 'storage/token_store.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// نقطة الإقفال الوحيدة (docs/23 §5): الميزة المطفأة **لا تُسجَّل**، بدل
|
||||
/// `if` مبعثرة في الشاشات.
|
||||
Future<void> setupInjector() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
sl.registerSingleton<SharedPreferences>(prefs);
|
||||
|
||||
const secure = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final tokens = TokenStore(secure);
|
||||
await tokens.load();
|
||||
await _ensureDeviceId(tokens);
|
||||
sl.registerSingleton<TokenStore>(tokens);
|
||||
|
||||
sl.registerSingleton<SettingsCubit>(SettingsCubit(prefs));
|
||||
|
||||
// انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر
|
||||
// يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor.
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient.create(
|
||||
tokens: tokens,
|
||||
onSessionExpired: () async => sl<SessionCubit>().onSessionExpired(),
|
||||
),
|
||||
);
|
||||
|
||||
sl.registerSingleton<AuthRepository>(AuthRepository(sl(), sl()));
|
||||
sl.registerSingleton<SessionCubit>(SessionCubit(sl()));
|
||||
|
||||
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
|
||||
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
|
||||
}
|
||||
|
||||
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
|
||||
/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل
|
||||
/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**.
|
||||
Future<void> _ensureDeviceId(TokenStore tokens) async {
|
||||
if (await tokens.deviceId() != null) return;
|
||||
final info = DeviceInfoPlugin();
|
||||
String? id;
|
||||
try {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
id = (await info.androidInfo).id;
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
id = (await info.iosInfo).identifierForVendor;
|
||||
}
|
||||
} catch (_) {
|
||||
// منصّة لا تجيب — المعرّف الفارغ أفضل من إقلاع فاشل.
|
||||
}
|
||||
if (id != null && id.isNotEmpty) await tokens.setDeviceId(id);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// يُنادى مرة واحدة قبل `runApp`.
|
||||
Future<void> setupInjector() async {
|
||||
sl.registerSingleton<SharedPreferences>(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
const secure = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final tokens = TokenStore(secure);
|
||||
await tokens.load();
|
||||
sl.registerSingleton<TokenStore>(tokens);
|
||||
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient.create(
|
||||
tokens: tokens,
|
||||
// يُربط بالتوجيه في م3 (إخراج للمستخدم عند انتهاء الجلسة).
|
||||
onSessionExpired: () async {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'session/session_cubit.dart';
|
||||
import 'session/session_state.dart';
|
||||
import '../features/auth/view/login_page.dart';
|
||||
import '../features/auth/view/profile_page.dart';
|
||||
import '../features/home/view/home_page.dart';
|
||||
import '../features/splash/view/splash_page.dart';
|
||||
import 'di.dart';
|
||||
|
||||
class Routes {
|
||||
const Routes._();
|
||||
static const splash = '/';
|
||||
static const login = '/login';
|
||||
static const profile = '/profile';
|
||||
static const home = '/home';
|
||||
}
|
||||
|
||||
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
||||
/// موزّعة في الشاشات، فلا تتناقض حالتان.
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: Routes.splash,
|
||||
refreshListenable: _CubitListenable(sl<SessionCubit>().stream),
|
||||
redirect: (context, state) {
|
||||
final session = sl<SessionCubit>().state;
|
||||
final loc = state.matchedLocation;
|
||||
|
||||
// قبل أن نعرف: نبقى على الإقلاع ولا نقرّر شيئاً.
|
||||
if (!session.isReady) return loc == Routes.splash ? null : Routes.splash;
|
||||
|
||||
return switch (session.status) {
|
||||
SessionStatus.unauthenticated =>
|
||||
loc == Routes.login ? null : Routes.login,
|
||||
SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile,
|
||||
SessionStatus.authenticated =>
|
||||
(loc == Routes.login || loc == Routes.profile || loc == Routes.splash)
|
||||
? Routes.home
|
||||
: null,
|
||||
SessionStatus.unknown => Routes.splash,
|
||||
};
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.login,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.profile,
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.home,
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// جسر بين تيّار الـCubit و`refreshListenable` الذي يطلبه go_router.
|
||||
class _CubitListenable extends ChangeNotifier {
|
||||
_CubitListenable(Stream<dynamic> stream) {
|
||||
_sub = stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// المستخدم كما يرجّعه الخادم (docs/38 §2 — شكل مُتحقَّق حيّاً).
|
||||
class AppUser extends Equatable {
|
||||
const AppUser({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.phone,
|
||||
required this.role,
|
||||
required this.status,
|
||||
required this.language,
|
||||
this.name,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
|
||||
/// مطبَّع دولياً من الخادم (`0790000001` ← `962790000001`) — لا يُقارن نصّياً
|
||||
/// بما أدخله المستخدم (docs/38 §2).
|
||||
final String phone;
|
||||
|
||||
final String role;
|
||||
final String status;
|
||||
final String language;
|
||||
final String? name;
|
||||
|
||||
/// نصّ لا رقم — كل الأرقام العشرية تصل نصوصاً (docs/38 §12.2).
|
||||
final String? rating;
|
||||
|
||||
/// `phone_bidx` يصل في الرد لكنه فهرس أعمى للخادم — **لا يُخزَّن ولا يُعرض**.
|
||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||
return AppUser(
|
||||
id: json['id'] as String,
|
||||
tenantId: json['tenant_id'] as String,
|
||||
phone: json['phone'] as String,
|
||||
role: json['role'] as String? ?? 'rider',
|
||||
status: json['status'] as String? ?? 'active',
|
||||
language: json['language'] as String? ?? 'ar',
|
||||
name: json['name'] as String?,
|
||||
rating: json['rating']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// الملف ناقص ما لم يُدخل المستخدم اسمه — يقود شاشة إكمال الملف.
|
||||
bool get needsProfile => name == null || name!.trim().isEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, tenantId, phone, role, status, language, name];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../api/api_exception.dart';
|
||||
|
||||
import 'app_user.dart';
|
||||
import 'session_source.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
/// حالة الجلسة على مستوى التطبيق — مصدر قرار التوجيه الوحيد
|
||||
/// (`core/router.dart` يستمع إليها).
|
||||
class SessionCubit extends Cubit<SessionState> {
|
||||
SessionCubit(this._repo) : super(const SessionState());
|
||||
|
||||
final SessionSource _repo;
|
||||
|
||||
/// تُستدعى مرة عند الإقلاع من شاشة الـsplash.
|
||||
Future<void> restore() async {
|
||||
if (!_repo.hasSession) {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_apply(await _repo.me());
|
||||
} on ApiException catch (e) {
|
||||
// 401 هنا يعني رمز تحديث ميّت — الـinterceptor مسح المخزن أصلاً.
|
||||
// أي خطأ آخر (شبكة) لا يُسقط الجلسة: المستخدم مسجّل، الشبكة هي الغائبة.
|
||||
if (e.isUnauthorized) {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
} else {
|
||||
emit(const SessionState(status: SessionStatus.authenticated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onLoggedIn(AppUser user) => _apply(user);
|
||||
|
||||
void onProfileCompleted(AppUser user) => _apply(user);
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repo.logout();
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
}
|
||||
|
||||
/// انتهاء الجلسة من طبقة الشبكة (رمز تحديث ميّت) — لا يمرّ بالمستودع.
|
||||
void onSessionExpired() {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
}
|
||||
|
||||
void _apply(AppUser user) {
|
||||
emit(SessionState(
|
||||
status: user.needsProfile
|
||||
? SessionStatus.needsProfile
|
||||
: SessionStatus.authenticated,
|
||||
user: user,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'app_user.dart';
|
||||
|
||||
/// ما تحتاجه الجلسة من طبقة المصادقة — **ولا شيء أكثر**.
|
||||
///
|
||||
/// `SessionCubit` حالةٌ على مستوى التطبيق (الراوتر والشاشات كلها تقرأها)،
|
||||
/// فمحلّها `core/`. ولأن `core/` لا يجوز أن يستورد من `features/` كما لا
|
||||
/// تستورد ميزةٌ من أخرى (docs/23 §1)، تُعرّف الحاجة هنا كواجهة ضيّقة
|
||||
/// ويُنفّذها `AuthRepository` في ميزة المصادقة.
|
||||
abstract interface class SessionSource {
|
||||
Future<AppUser> me();
|
||||
Future<void> logout();
|
||||
bool get hasSession;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'app_user.dart';
|
||||
|
||||
enum SessionStatus {
|
||||
/// قبل أن نعرف — تُعرض شاشة الإقلاع، ولا يُتّخذ قرار توجيه.
|
||||
unknown,
|
||||
authenticated,
|
||||
|
||||
/// مصادَق لكن بلا اسم — يُوجَّه لإكمال الملف قبل أي شاشة أخرى.
|
||||
needsProfile,
|
||||
unauthenticated,
|
||||
}
|
||||
|
||||
class SessionState extends Equatable {
|
||||
const SessionState({this.status = SessionStatus.unknown, this.user});
|
||||
|
||||
final SessionStatus status;
|
||||
final AppUser? user;
|
||||
|
||||
bool get isReady => status != SessionStatus.unknown;
|
||||
|
||||
SessionState copyWith({SessionStatus? status, AppUser? user}) {
|
||||
return SessionState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, user];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// سمة مبدئية — تُستبدل بنظام التصميم الكامل في م2 (docs/26).
|
||||
/// الغرض الآن: تثبيت الخطوط وأن يقلع التطبيق بشكل صحيح، لا أكثر.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static const seed = Color(0xFF1F6FEB);
|
||||
static const arabicFont = 'IBMPlexSansArabic';
|
||||
|
||||
static ThemeData light() => _base(Brightness.light);
|
||||
static ThemeData dark() => _base(Brightness.dark);
|
||||
|
||||
static ThemeData _base(Brightness brightness) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: arabicFont,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'tripz_button.dart';
|
||||
|
||||
/// حالة الفراغ: رسالة **تدلّ على الفعل التالي** لا مجرّد «لا يوجد»
|
||||
/// (docs/26 §8.4).
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_rounded,
|
||||
this.message,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String? message;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Space.page,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: Sizes.iconLg,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(
|
||||
message ?? context.l10n.emptyDefault,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.texts.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (onAction != null && actionLabel != null) ...[
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzButton.secondary(label: actionLabel!, onPressed: onAction),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'tripz_button.dart';
|
||||
|
||||
/// حالة الخطأ: رسالة **بالعربية** مع «أعد المحاولة» — لا شاشة صامتة
|
||||
/// (docs/26 §8.4). الرسالة تأتي جاهزة من الـCubit (docs/23 §3).
|
||||
class ErrorView extends StatelessWidget {
|
||||
const ErrorView({super.key, this.message, this.onRetry});
|
||||
|
||||
final String? message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Space.page,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: Sizes.iconLg,
|
||||
color: context.tripzColors.danger,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(
|
||||
message ?? context.l10n.errorGeneric,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.texts.bodyMedium,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzButton.secondary(
|
||||
label: context.l10n.actionRetry,
|
||||
onPressed: onRetry,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
|
||||
/// هيكل تحميل — **لا spinner فارغ** (docs/26 §8.4).
|
||||
///
|
||||
/// النبض `AnimatedBuilder` على `Opacity` وحدها: لا يُعاد بناء الشجرة تحته،
|
||||
/// وهذا ما يفرق بين skeleton رخيص وآخر يأكل إطارات.
|
||||
class Skeleton extends StatefulWidget {
|
||||
const Skeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.radius = Radii.field,
|
||||
});
|
||||
|
||||
/// ثلاثة أسطر بأطوال متفاوتة — الشكل الافتراضي لأي قائمة تُحمَّل.
|
||||
const Skeleton.lines({super.key})
|
||||
: width = null,
|
||||
height = -1,
|
||||
radius = Radii.field;
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<Skeleton> createState() => _SkeletonState();
|
||||
}
|
||||
|
||||
class _SkeletonState extends State<Skeleton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = context.tripzColors.skeleton;
|
||||
|
||||
final child = widget.height < 0
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_bar(color, double.infinity),
|
||||
const SizedBox(height: Space.sm),
|
||||
_bar(color, 220),
|
||||
const SizedBox(height: Space.sm),
|
||||
_bar(color, 140),
|
||||
],
|
||||
)
|
||||
: _bar(color, widget.width ?? double.infinity, widget.height);
|
||||
|
||||
// تُحترم إعدادات تقليل الحركة في النظام (docs/26 §8.5).
|
||||
if (MediaQuery.disableAnimationsOf(context)) return child;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, inner) => Opacity(
|
||||
opacity: 0.45 + (_controller.value * 0.35),
|
||||
child: inner,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bar(Color color, double width, [double height = 16]) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
|
||||
enum BannerTone { info, success, warning, danger }
|
||||
|
||||
/// شريط الحالة الحيّ (docs/26 §7) — حالة الرحلة، تحذير الرصيد، انقطاع الشبكة.
|
||||
/// اللون من الأدوار الدلالية لا من hex.
|
||||
class StatusBanner extends StatelessWidget {
|
||||
const StatusBanner({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.tone = BannerTone.info,
|
||||
this.icon,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final BannerTone tone;
|
||||
final IconData? icon;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.tripzColors;
|
||||
final color = switch (tone) {
|
||||
BannerTone.info => c.info,
|
||||
BannerTone.success => c.success,
|
||||
BannerTone.warning => c.warning,
|
||||
BannerTone.danger => c.danger,
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.sm,
|
||||
vertical: Space.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: Radii.field_,
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon ?? Icons.info_outline_rounded, size: Sizes.iconSm, color: color),
|
||||
const SizedBox(width: Space.xs),
|
||||
Expanded(
|
||||
child: Text(message, style: context.texts.bodySmall),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
enum _ButtonKind { primary, secondary, text }
|
||||
|
||||
/// الزر الموحّد (docs/26 §7). ارتفاع 52، وحالة تحميل **داخلية**: الشاشة
|
||||
/// تمرّر `loading: true` ولا تستبدل الزر بمؤشّر — فلا يقفز التخطيط.
|
||||
class TripzButton extends StatelessWidget {
|
||||
const TripzButton.primary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = true,
|
||||
}) : _kind = _ButtonKind.primary;
|
||||
|
||||
const TripzButton.secondary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = false,
|
||||
}) : _kind = _ButtonKind.secondary;
|
||||
|
||||
const TripzButton.text({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = false,
|
||||
}) : _kind = _ButtonKind.text;
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final IconData? icon;
|
||||
final bool expanded;
|
||||
final _ButtonKind _kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// أثناء التحميل يُعطَّل الزر — يمنع الإرسال المزدوج بلا حارس في كل شاشة.
|
||||
final onTap = loading ? null : onPressed;
|
||||
final child = _Content(label: label, icon: icon, loading: loading);
|
||||
|
||||
final button = switch (_kind) {
|
||||
_ButtonKind.primary => FilledButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
_ButtonKind.secondary => OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
_ButtonKind.text => TextButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
|
||||
if (!expanded) return button;
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
|
||||
ButtonStyle _style(BuildContext context) => ButtonStyle(
|
||||
minimumSize: const WidgetStatePropertyAll(
|
||||
Size(0, Sizes.control),
|
||||
),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: Radii.card_),
|
||||
),
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: Space.lg),
|
||||
),
|
||||
textStyle: WidgetStatePropertyAll(
|
||||
Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Content extends StatelessWidget {
|
||||
const _Content({required this.label, this.icon, required this.loading});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (loading) {
|
||||
return const SizedBox(
|
||||
width: Sizes.iconSm,
|
||||
height: Sizes.iconSm,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.2),
|
||||
);
|
||||
}
|
||||
if (icon == null) return Text(label);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: Sizes.iconSm),
|
||||
const SizedBox(width: Space.xs),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// بطاقة موحّدة (docs/26 §7): الرحلة · السائق · المحفظة.
|
||||
/// الحدّ لا الظل — «الوضوح قبل الزينة» (docs/26 §8.2).
|
||||
class TripzCard extends StatelessWidget {
|
||||
const TripzCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.padding = Space.page,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Padding(padding: padding, child: child);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: onTap == null
|
||||
? content
|
||||
: InkWell(onTap: onTap, child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/typography.dart';
|
||||
|
||||
/// حقل رمز التحقّق — أربع خانات (طول رمز تريبز، docs/38 §2).
|
||||
///
|
||||
/// حقل حقيقي واحد تحت خانات معروضة: يُبقي اللصق والإكمال التلقائي من الرسالة
|
||||
/// (`AutofillHints.oneTimeCode`) يعملان — وهو ما يكسره تقسيمُه إلى أربعة
|
||||
/// حقول منفصلة. يُستدعى [onCompleted] عند اكتمال الطول.
|
||||
class TripzOtpField extends StatefulWidget {
|
||||
const TripzOtpField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.length = 4,
|
||||
this.onCompleted,
|
||||
this.enabled = true,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final int length;
|
||||
final ValueChanged<String>? onCompleted;
|
||||
final bool enabled;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
State<TripzOtpField> createState() => _TripzOtpFieldState();
|
||||
}
|
||||
|
||||
class _TripzOtpFieldState extends State<TripzOtpField> {
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// الحقل الفعلي — شفّاف بلا حجم بصري، يستقبل الكيبورد واللصق.
|
||||
SizedBox(
|
||||
height: Sizes.control,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focus,
|
||||
enabled: widget.enabled,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: widget.length,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
onChanged: (v) {
|
||||
if (v.length == widget.length) widget.onCompleted?.call(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _focus.requestFocus(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, value, _) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
children: List.generate(widget.length, (i) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Space.xxs),
|
||||
child: _Box(
|
||||
char: i < value.text.length ? value.text[i] : null,
|
||||
focused: _focus.hasFocus && i == value.text.length,
|
||||
hasError: widget.hasError,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Box extends StatelessWidget {
|
||||
const _Box({
|
||||
required this.char,
|
||||
required this.focused,
|
||||
required this.hasError,
|
||||
});
|
||||
|
||||
final String? char;
|
||||
final bool focused;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final borderColor = hasError
|
||||
? scheme.error
|
||||
: focused
|
||||
? scheme.primary
|
||||
: scheme.outlineVariant;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: Motion.tap,
|
||||
curve: Motion.curve,
|
||||
width: 56,
|
||||
height: Sizes.control,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: Radii.field_,
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: focused || hasError ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
char ?? '',
|
||||
style: numericStyle(Theme.of(context).textTheme.headlineSmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import 'empty_view.dart';
|
||||
import 'error_view.dart';
|
||||
import 'skeleton.dart';
|
||||
import 'view_status.dart';
|
||||
|
||||
/// نقطة التنظيم المركزية (docs/26 §6). **ممنوع `Scaffold` خام في
|
||||
/// `features/`.**
|
||||
///
|
||||
/// هو ما يحوّل `status` القادم من الـCubit إلى skeleton/خطأ/فراغ/محتوى — فلا
|
||||
/// تكتب أي شاشة `if (loading)`، وتغيير شكل «التحميل» في التطبيق كله يصير
|
||||
/// سطراً واحداً هنا.
|
||||
class TripzScaffold extends StatelessWidget {
|
||||
const TripzScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.status = ViewStatus.success,
|
||||
this.error,
|
||||
this.onRetry,
|
||||
this.empty,
|
||||
this.loading,
|
||||
this.bottomAction,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.showAppBar = true,
|
||||
this.padded = true,
|
||||
this.resizeToAvoidBottomInset = true,
|
||||
});
|
||||
|
||||
/// المحتوى — يُبنى **عند النجاح فقط**.
|
||||
final Widget child;
|
||||
|
||||
final String? title;
|
||||
final ViewStatus status;
|
||||
|
||||
/// رسالة الخطأ كما أصدرها الـCubit، بلا إعادة صياغة في الواجهة.
|
||||
final String? error;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// شكل الفراغ الخاص بالشاشة — وإلا فالافتراضي.
|
||||
final Widget? empty;
|
||||
|
||||
/// شكل التحميل الخاص بالشاشة — وإلا فهيكل ثلاثة أسطر.
|
||||
final Widget? loading;
|
||||
|
||||
/// خانة الـCTA السفلية: ترتفع فوق الكيبورد، وتحترم المنطقة الآمنة.
|
||||
final Widget? bottomAction;
|
||||
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool showAppBar;
|
||||
final bool padded;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
appBar: !showAppBar
|
||||
? null
|
||||
: AppBar(
|
||||
title: title == null ? null : Text(title!),
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
),
|
||||
body: SafeArea(
|
||||
top: !showAppBar,
|
||||
child: Padding(
|
||||
padding: padded ? Space.page : EdgeInsets.zero,
|
||||
child: _Body(
|
||||
status: status,
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
empty: empty,
|
||||
loading: loading,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: bottomAction == null
|
||||
? null
|
||||
: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
// viewInsets يرفع الـCTA فوق الكيبورد بدل أن يختفي تحته.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Space.md,
|
||||
Space.xs,
|
||||
Space.md,
|
||||
Space.md + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: bottomAction,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.status,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.empty,
|
||||
required this.loading,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final String? error;
|
||||
final VoidCallback? onRetry;
|
||||
final Widget? empty;
|
||||
final Widget? loading;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (status) {
|
||||
ViewStatus.idle ||
|
||||
ViewStatus.loading =>
|
||||
loading ?? const Skeleton.lines(),
|
||||
ViewStatus.error => ErrorView(message: error, onRetry: onRetry),
|
||||
ViewStatus.empty => empty ?? const EmptyView(),
|
||||
ViewStatus.success => child,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// الورقة السفلية — **الحوار الافتراضي في التطبيق sheet لا dialog**
|
||||
/// (docs/26 §7). المقبض والزوايا من الثيم، فلا تُعاد كتابتهما في كل نداء.
|
||||
class TripzSheet extends StatelessWidget {
|
||||
const TripzSheet({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.bottomAction,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String? title;
|
||||
final Widget? bottomAction;
|
||||
|
||||
/// [dismissible] = false للحالات التي لا تُغلق بالسحب — مثل شاشة البحث عن
|
||||
/// سائق: تُغلق بزر الإلغاء وحده كي لا يزيحها المستخدم بالخطأ (docs/39 §3).
|
||||
static Future<T?> show<T>(
|
||||
BuildContext context, {
|
||||
required Widget child,
|
||||
String? title,
|
||||
Widget? bottomAction,
|
||||
bool dismissible = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: dismissible,
|
||||
enableDrag: dismissible,
|
||||
showDragHandle: dismissible,
|
||||
builder: (_) => TripzSheet(
|
||||
title: title,
|
||||
bottomAction: bottomAction,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Space.md,
|
||||
Space.xs,
|
||||
Space.md,
|
||||
Space.md + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(title!, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: Space.md),
|
||||
],
|
||||
Flexible(child: child),
|
||||
if (bottomAction != null) ...[
|
||||
const SizedBox(height: Space.md),
|
||||
bottomAction!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/typography.dart';
|
||||
|
||||
/// حقل الإدخال الموحّد (docs/26 §7): نصّ · هاتف · بحث.
|
||||
///
|
||||
/// رسالة الخطأ تحته بالعربية، والأرقام لاتينية tabular دائماً — رقم الهاتف
|
||||
/// ورمز التحقّق لا «يرقصان» أثناء الكتابة.
|
||||
class TripzTextField extends StatelessWidget {
|
||||
const TripzTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.errorText,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.enabled = true,
|
||||
this.maxLength,
|
||||
this.prefixIcon,
|
||||
this.suffix,
|
||||
this.inputFormatters,
|
||||
this.autofillHints,
|
||||
this.numeric = false,
|
||||
});
|
||||
|
||||
/// حقل هاتف: أرقام فقط، لوحة مفاتيح هاتف، إكمال تلقائي من النظام.
|
||||
factory TripzTextField.phone({
|
||||
Key? key,
|
||||
TextEditingController? controller,
|
||||
String? label,
|
||||
String? hint,
|
||||
String? errorText,
|
||||
ValueChanged<String>? onChanged,
|
||||
ValueChanged<String>? onSubmitted,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return TripzTextField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
label: label,
|
||||
hint: hint,
|
||||
errorText: errorText,
|
||||
enabled: enabled,
|
||||
numeric: true,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
maxLength: 15,
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
autofillHints: const [AutofillHints.telephoneNumber],
|
||||
);
|
||||
}
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final String? errorText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
final bool enabled;
|
||||
final int? maxLength;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffix;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final Iterable<String>? autofillHints;
|
||||
final bool numeric;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.bodyLarge;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
autofocus: autofocus,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
maxLength: maxLength,
|
||||
inputFormatters: inputFormatters,
|
||||
autofillHints: autofillHints,
|
||||
style: numeric ? numericStyle(base) : base,
|
||||
// الأرقام تُقرأ يساراً-ليميناً حتى في واجهة عربية.
|
||||
textDirection: numeric ? TextDirection.ltr : null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: errorText,
|
||||
counterText: '',
|
||||
prefixIcon: prefixIcon == null
|
||||
? null
|
||||
: Icon(prefixIcon, size: Sizes.iconSm),
|
||||
suffixIcon: suffix,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// حالة العرض الموحّدة التي يُصدرها كل Cubit (docs/23 §3) ويستهلكها
|
||||
/// `TripzScaffold` (docs/26 §6).
|
||||
///
|
||||
/// هذا ما يجعل نظام التصميم متوافقاً مع Cubit **بالبنية لا بالاتفاق**:
|
||||
/// الشاشة لا تكتب `if (loading)` أبداً.
|
||||
enum ViewStatus {
|
||||
/// لم يبدأ شيء بعد — يُعرض كتحميل لأن المستخدم لا يفرّق.
|
||||
idle,
|
||||
loading,
|
||||
success,
|
||||
|
||||
/// نجح النداء ولا بيانات — يُعرض `EmptyView`.
|
||||
empty,
|
||||
error,
|
||||
}
|
||||
|
||||
extension ViewStatusX on ViewStatus {
|
||||
bool get isLoading => this == ViewStatus.idle || this == ViewStatus.loading;
|
||||
bool get isError => this == ViewStatus.error;
|
||||
bool get isEmpty => this == ViewStatus.empty;
|
||||
bool get isSuccess => this == ViewStatus.success;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../../../core/api/api_exception.dart';
|
||||
|
||||
/// سبب الفشل كـ**رمز** لا كنصّ.
|
||||
///
|
||||
/// docs/23 §3 يقول إن الـCubit يحوّل خطأ الشبكة إلى رسالة عربية؛ وdocs/26 §4
|
||||
/// (الأحدث، وهو الذي «يفعّل قاعدة docs/23 §2 التي كانت بلا أداة») يوجب مرور
|
||||
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
|
||||
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
|
||||
/// الشاشة بلا منطق، والنص بلغتين.
|
||||
enum AuthFailure {
|
||||
/// رمز خاطئ أو منتهي الصلاحية.
|
||||
invalidCode,
|
||||
|
||||
/// تجاوز عدد المحاولات — الخادم يُبطل الرمز فوراً (docs/38 §2).
|
||||
tooManyAttempts,
|
||||
|
||||
/// تجاوز حدّ طلب الرمز: ثلاثة كل خمس دقائق.
|
||||
rateLimited,
|
||||
|
||||
network,
|
||||
timeout,
|
||||
server,
|
||||
}
|
||||
|
||||
extension AuthFailureX on ApiException {
|
||||
AuthFailure toAuthFailure() {
|
||||
if (isRateLimited) return AuthFailure.rateLimited;
|
||||
if (kind == ApiErrorKind.network) return AuthFailure.network;
|
||||
if (kind == ApiErrorKind.timeout) return AuthFailure.timeout;
|
||||
if (isUnauthorized) {
|
||||
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
|
||||
return message.contains('Too many')
|
||||
? AuthFailure.tooManyAttempts
|
||||
: AuthFailure.invalidCode;
|
||||
}
|
||||
return AuthFailure.server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/config.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import 'auth_failure.dart';
|
||||
import 'login_state.dart';
|
||||
import 'phone_error.dart';
|
||||
|
||||
/// تدفّق الدخول كاملاً: الشروط ← إذن الموقع ← الهاتف ← الرمز.
|
||||
///
|
||||
/// الـCubit لا يعرف Flutter: لا `BuildContext` ولا تنقّل ولا `SnackBar`
|
||||
/// (docs/23 §3). يُصدر حالة، والواجهة تتصرّف.
|
||||
class LoginCubit extends Cubit<LoginState> {
|
||||
LoginCubit(this._repo, this._prefs) : super(const LoginState()) {
|
||||
_resolveInitialStep();
|
||||
}
|
||||
|
||||
final AuthRepository _repo;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kAgreed = 'auth.agreed_terms';
|
||||
|
||||
Timer? _resendTimer;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_resendTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
// ── البوابتان ──────────────────────────────────────────────────────────
|
||||
|
||||
void _resolveInitialStep() {
|
||||
if (_prefs.getBool(_kAgreed) != true) {
|
||||
emit(state.copyWith(step: LoginStep.agreement));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(step: LoginStep.permission, agreed: true));
|
||||
unawaited(checkLocationPermission());
|
||||
}
|
||||
|
||||
void toggleAgreement(bool value) => emit(state.copyWith(agreed: value));
|
||||
|
||||
Future<void> acceptAgreement() async {
|
||||
if (!state.agreed) return;
|
||||
await _prefs.setBool(_kAgreed, true);
|
||||
emit(state.copyWith(step: LoginStep.permission));
|
||||
await checkLocationPermission();
|
||||
}
|
||||
|
||||
/// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى
|
||||
/// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2).
|
||||
Future<void> checkLocationPermission() async {
|
||||
final status = await Permission.locationWhenInUse.status;
|
||||
if (status.isGranted || status.isLimited) {
|
||||
emit(state.copyWith(step: LoginStep.phone));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(
|
||||
permissionPermanentlyDenied: status.isPermanentlyDenied,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> requestLocationPermission() async {
|
||||
final status = await Permission.locationWhenInUse.request();
|
||||
if (status.isGranted || status.isLimited) {
|
||||
emit(state.copyWith(step: LoginStep.phone));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(
|
||||
permissionPermanentlyDenied: status.isPermanentlyDenied,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> openSystemSettings() => openAppSettings();
|
||||
|
||||
// ── الهاتف ─────────────────────────────────────────────────────────────
|
||||
|
||||
void phoneChanged(String value) {
|
||||
emit(state.copyWith(
|
||||
phone: value,
|
||||
clearPhoneError: true,
|
||||
clearFailure: true,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> submitPhone() async {
|
||||
final error = validatePhone(state.phone);
|
||||
if (error != null) {
|
||||
emit(state.copyWith(phoneError: error));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
await _repo.sendOtp(state.phone.trim());
|
||||
emit(state.copyWith(status: LoginStatus.idle, step: LoginStep.otp));
|
||||
_startResendCountdown();
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void editPhone() {
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
step: LoginStep.phone,
|
||||
status: LoginStatus.idle,
|
||||
resendIn: 0,
|
||||
clearFailure: true,
|
||||
));
|
||||
}
|
||||
|
||||
// ── الرمز ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> resendOtp() async {
|
||||
if (!state.canResend) return;
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
await _repo.sendOtp(state.phone.trim());
|
||||
emit(state.copyWith(status: LoginStatus.idle));
|
||||
_startResendCountdown();
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
|
||||
// فوراً وعمّق الحظر.
|
||||
_startResendCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitOtp(String code) async {
|
||||
if (code.length != AppConfig.otpLength) return;
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
final user = await _repo.verifyOtp(phone: state.phone.trim(), code: code);
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.success,
|
||||
step: LoginStep.done,
|
||||
user: user,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _startResendCountdown() {
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(resendIn: AppConfig.otpResendCooldown.inSeconds));
|
||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
final next = state.resendIn - 1;
|
||||
if (next <= 0) timer.cancel();
|
||||
emit(state.copyWith(resendIn: next < 0 ? 0 : next));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../core/session/app_user.dart';
|
||||
import 'auth_failure.dart';
|
||||
import 'phone_error.dart';
|
||||
|
||||
/// خطوات الدخول بالترتيب الميداني المثبت في سيرو (docs/39 §2): بوابتان
|
||||
/// كاملتان **قبل** أي حقل إدخال، ثم الهاتف فالرمز.
|
||||
enum LoginStep { agreement, permission, phone, otp, done }
|
||||
|
||||
enum LoginStatus { idle, submitting, failure, success }
|
||||
|
||||
class LoginState extends Equatable {
|
||||
const LoginState({
|
||||
this.step = LoginStep.agreement,
|
||||
this.status = LoginStatus.idle,
|
||||
this.phone = '',
|
||||
this.agreed = false,
|
||||
this.failure,
|
||||
this.phoneError,
|
||||
this.resendIn = 0,
|
||||
this.permissionPermanentlyDenied = false,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final LoginStep step;
|
||||
final LoginStatus status;
|
||||
|
||||
/// الرقم كما أدخله المستخدم — الخادم يطبّعه، فلا نطبّعه نحن (docs/38 §2).
|
||||
final String phone;
|
||||
|
||||
final bool agreed;
|
||||
final AuthFailure? failure;
|
||||
final PhoneError? phoneError;
|
||||
|
||||
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
|
||||
/// خمس دقائق، فالعدّاد ليس تجميلاً (docs/39 §2).
|
||||
final int resendIn;
|
||||
|
||||
/// رُفض الإذن نهائياً — الحل الوحيد فتح إعدادات النظام.
|
||||
final bool permissionPermanentlyDenied;
|
||||
|
||||
final AppUser? user;
|
||||
|
||||
bool get isSubmitting => status == LoginStatus.submitting;
|
||||
bool get canResend => resendIn == 0 && !isSubmitting;
|
||||
|
||||
LoginState copyWith({
|
||||
LoginStep? step,
|
||||
LoginStatus? status,
|
||||
String? phone,
|
||||
bool? agreed,
|
||||
AuthFailure? failure,
|
||||
PhoneError? phoneError,
|
||||
int? resendIn,
|
||||
bool? permissionPermanentlyDenied,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
bool clearPhoneError = false,
|
||||
}) {
|
||||
return LoginState(
|
||||
step: step ?? this.step,
|
||||
status: status ?? this.status,
|
||||
phone: phone ?? this.phone,
|
||||
agreed: agreed ?? this.agreed,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
phoneError: clearPhoneError ? null : (phoneError ?? this.phoneError),
|
||||
resendIn: resendIn ?? this.resendIn,
|
||||
permissionPermanentlyDenied:
|
||||
permissionPermanentlyDenied ?? this.permissionPermanentlyDenied,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
step,
|
||||
status,
|
||||
phone,
|
||||
agreed,
|
||||
failure,
|
||||
phoneError,
|
||||
resendIn,
|
||||
permissionPermanentlyDenied,
|
||||
user,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// أخطاء تحقّق **محليّة** لرقم الهاتف — تُكتشف قبل أي نداء شبكة.
|
||||
/// الشروط الثلاثة منقولة من التحقّق المطبَّق ميدانياً في سيرو (docs/39 §2).
|
||||
enum PhoneError { empty, leadingZero, tooShort }
|
||||
|
||||
PhoneError? validatePhone(String raw) {
|
||||
final v = raw.trim();
|
||||
if (v.isEmpty) return PhoneError.empty;
|
||||
if (v.startsWith('0')) return PhoneError.leadingZero;
|
||||
if (v.length < 9) return PhoneError.tooShort;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/view_status.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import '../../../core/session/app_user.dart';
|
||||
import 'auth_failure.dart';
|
||||
|
||||
class ProfileState {
|
||||
const ProfileState({
|
||||
this.status = ViewStatus.success,
|
||||
this.saving = false,
|
||||
this.failure,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final bool saving;
|
||||
final AuthFailure? failure;
|
||||
final AppUser? user;
|
||||
|
||||
ProfileState copyWith({
|
||||
ViewStatus? status,
|
||||
bool? saving,
|
||||
AuthFailure? failure,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
}) {
|
||||
return ProfileState(
|
||||
status: status ?? this.status,
|
||||
saving: saving ?? this.saving,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// إكمال الملف بعد أول دخول. عقد الخادم يقبل `{ name, language }` فقط
|
||||
/// (docs/38 §3) — لا اسم أول/أخير ولا بريد كما في سيرو (docs/39 §2).
|
||||
class ProfileCubit extends Cubit<ProfileState> {
|
||||
ProfileCubit(this._repo) : super(const ProfileState());
|
||||
|
||||
final AuthRepository _repo;
|
||||
|
||||
Future<AppUser?> save(String name) async {
|
||||
emit(state.copyWith(saving: true, clearFailure: true));
|
||||
try {
|
||||
final user = await _repo.updateProfile(name: name.trim());
|
||||
emit(state.copyWith(saving: false, user: user));
|
||||
return user;
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/storage/token_store.dart';
|
||||
import '../../../core/session/app_user.dart';
|
||||
import '../../../core/session/session_source.dart';
|
||||
|
||||
/// كل نداءات المصادقة (docs/38 §2). الـCubit لا ينادي Dio مباشرة أبداً
|
||||
/// (docs/23 §3).
|
||||
class AuthRepository implements SessionSource {
|
||||
AuthRepository(this._api, this._tokens);
|
||||
|
||||
final ApiClient _api;
|
||||
final TokenStore _tokens;
|
||||
|
||||
/// حدّ الخادم: ثلاثة طلبات كل خمس دقائق — الواجهة تمنع الضغط المتكرر
|
||||
/// بعدّاد تنازلي (docs/39 §2).
|
||||
Future<void> sendOtp(String phone) async {
|
||||
await _api.post<Map<String, dynamic>>(
|
||||
'/auth/send-otp',
|
||||
body: {'phone': phone},
|
||||
);
|
||||
}
|
||||
|
||||
/// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها.
|
||||
Future<AppUser> verifyOtp({
|
||||
required String phone,
|
||||
required String code,
|
||||
String? referralCode,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/auth/verify-otp',
|
||||
body: {
|
||||
'phone': phone,
|
||||
'code': code,
|
||||
if (referralCode != null && referralCode.isNotEmpty)
|
||||
'referral_code': referralCode,
|
||||
},
|
||||
);
|
||||
|
||||
await _tokens.save(
|
||||
accessToken: res['access_token'] as String,
|
||||
refreshToken: res['refresh_token'] as String,
|
||||
// مفتاح توقيع الطلبات المالية (docs/38 §8) — يُخزَّن الآن ويُستهلك لاحقاً.
|
||||
signingKey: res['signing_key'] as String?,
|
||||
);
|
||||
|
||||
return AppUser.fromJson(res['user'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppUser> me() async {
|
||||
final res = await _api.get<Map<String, dynamic>>('/users/me');
|
||||
return AppUser.fromJson(res);
|
||||
}
|
||||
|
||||
Future<AppUser> updateProfile({String? name, String? language}) async {
|
||||
final res = await _api.patch<Map<String, dynamic>>(
|
||||
'/users/me',
|
||||
body: {
|
||||
'name': ?name,
|
||||
'language': ?language,
|
||||
},
|
||||
);
|
||||
return AppUser.fromJson(res);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() => _tokens.clear();
|
||||
|
||||
@override
|
||||
bool get hasSession => _tokens.isLoggedIn;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/design/tripz_colors.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../cubit/login_cubit.dart';
|
||||
import '../cubit/login_state.dart';
|
||||
import 'widgets/auth_header.dart';
|
||||
|
||||
/// البوابة الأولى: شاشة كاملة لا حوار، وزر متابعة **معطّل حتى يُؤشَّر**
|
||||
/// (docs/39 §2). تُحفظ الموافقة فلا تتكرر.
|
||||
class AgreementView extends StatelessWidget {
|
||||
const AgreementView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocBuilder<LoginCubit, LoginState>(
|
||||
buildWhen: (p, n) => p.agreed != n.agreed,
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<LoginCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.agreementAccept,
|
||||
onPressed: state.agreed ? cubit.acceptAgreement : null,
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
AuthHeader(
|
||||
icon: Icons.policy_outlined,
|
||||
title: l10n.agreementTitle,
|
||||
lead: l10n.agreementLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
CheckboxListTile(
|
||||
value: state.agreed,
|
||||
onChanged: (v) => cubit.toggleAgreement(v ?? false),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(
|
||||
l10n.agreementCheckbox,
|
||||
style: context.texts.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/di.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
import '../cubit/login_cubit.dart';
|
||||
import '../cubit/login_state.dart';
|
||||
import 'agreement_view.dart';
|
||||
import 'otp_view.dart';
|
||||
import 'permission_view.dart';
|
||||
import 'phone_view.dart';
|
||||
|
||||
/// مضيف تدفّق الدخول: خطوة واحدة معروضة في كل لحظة، والانتقال بينها من
|
||||
/// الـCubit لا من التنقّل — فلا يستطيع المستخدم الرجوع لخطوة تخطّاها.
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with WidgetsBindingObserver {
|
||||
late final LoginCubit _cubit = sl<LoginCubit>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState lifecycle) {
|
||||
// العودة من إعدادات النظام بعد منح الإذن يدوياً — بلا هذا الفحص تبقى
|
||||
// شاشة الإذن عالقة (حالة حافة مثبتة ميدانياً، docs/39 §2).
|
||||
if (lifecycle == AppLifecycleState.resumed &&
|
||||
_cubit.state.step == LoginStep.permission) {
|
||||
_cubit.checkLocationPermission();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocListener<LoginCubit, LoginState>(
|
||||
listenWhen: (prev, next) => next.step == LoginStep.done,
|
||||
listener: (context, state) {
|
||||
final user = state.user;
|
||||
if (user != null) context.read<SessionCubit>().onLoggedIn(user);
|
||||
},
|
||||
child: BlocBuilder<LoginCubit, LoginState>(
|
||||
buildWhen: (prev, next) => prev.step != next.step,
|
||||
builder: (context, state) => switch (state.step) {
|
||||
LoginStep.agreement => const AgreementView(),
|
||||
LoginStep.permission => const PermissionView(),
|
||||
LoginStep.phone => const PhoneView(),
|
||||
LoginStep.otp || LoginStep.done => const OtpView(),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/design/tripz_colors.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_otp_field.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../cubit/login_cubit.dart';
|
||||
import '../cubit/login_state.dart';
|
||||
import 'widgets/auth_failure_text.dart';
|
||||
import 'widgets/auth_header.dart';
|
||||
|
||||
class OtpView extends StatefulWidget {
|
||||
const OtpView({super.key});
|
||||
|
||||
@override
|
||||
State<OtpView> createState() => _OtpViewState();
|
||||
}
|
||||
|
||||
class _OtpViewState extends State<OtpView> {
|
||||
final _code = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocBuilder<LoginCubit, LoginState>(
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<LoginCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.otpVerify,
|
||||
loading: state.isSubmitting,
|
||||
onPressed: () => cubit.submitOtp(_code.text),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
AuthHeader(
|
||||
icon: Icons.mark_email_read_outlined,
|
||||
title: l10n.otpTitle,
|
||||
lead: l10n.otpLead(state.phone),
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzOtpField(
|
||||
controller: _code,
|
||||
enabled: !state.isSubmitting,
|
||||
hasError: state.failure != null,
|
||||
onCompleted: cubit.submitOtp,
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
const SizedBox(height: Space.md),
|
||||
// العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات
|
||||
// المستخدمَ خمس دقائق (docs/38 §2).
|
||||
Center(
|
||||
child: state.canResend
|
||||
? TripzButton.text(
|
||||
label: l10n.otpResend,
|
||||
onPressed: cubit.resendOtp,
|
||||
)
|
||||
: Text(
|
||||
l10n.otpResendIn(state.resendIn),
|
||||
style: context.texts.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TripzButton.text(
|
||||
label: l10n.otpChangeNumber,
|
||||
onPressed: cubit.editPhone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/status_banner.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../cubit/login_cubit.dart';
|
||||
import '../cubit/login_state.dart';
|
||||
import 'widgets/auth_header.dart';
|
||||
|
||||
/// البوابة الثانية: إذن الموقع. عند الرفض النهائي لا يبقى إلا فتح إعدادات
|
||||
/// النظام — والعودة منها يلتقطها `LoginPage` بمراقبة دورة الحياة.
|
||||
class PermissionView extends StatelessWidget {
|
||||
const PermissionView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocBuilder<LoginCubit, LoginState>(
|
||||
buildWhen: (p, n) =>
|
||||
p.permissionPermanentlyDenied != n.permissionPermanentlyDenied,
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<LoginCubit>();
|
||||
final denied = state.permissionPermanentlyDenied;
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: denied ? l10n.actionOpenSettings : l10n.permissionAllow,
|
||||
onPressed: denied
|
||||
? cubit.openSystemSettings
|
||||
: cubit.requestLocationPermission,
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
AuthHeader(
|
||||
icon: Icons.my_location_rounded,
|
||||
title: l10n.permissionTitle,
|
||||
lead: l10n.permissionLead,
|
||||
),
|
||||
if (denied) ...[
|
||||
const SizedBox(height: Space.lg),
|
||||
StatusBanner(
|
||||
message: l10n.permissionDeniedForever,
|
||||
tone: BannerTone.warning,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_text_field.dart';
|
||||
import '../cubit/login_cubit.dart';
|
||||
import '../cubit/login_state.dart';
|
||||
import 'widgets/auth_failure_text.dart';
|
||||
import 'widgets/auth_header.dart';
|
||||
|
||||
class PhoneView extends StatelessWidget {
|
||||
const PhoneView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocBuilder<LoginCubit, LoginState>(
|
||||
buildWhen: (p, n) =>
|
||||
p.status != n.status ||
|
||||
p.phoneError != n.phoneError ||
|
||||
p.failure != n.failure,
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<LoginCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.phoneSend,
|
||||
loading: state.isSubmitting,
|
||||
onPressed: cubit.submitPhone,
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
AuthHeader(
|
||||
icon: Icons.phone_outlined,
|
||||
title: l10n.phoneTitle,
|
||||
lead: l10n.phoneLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzTextField.phone(
|
||||
label: l10n.phoneLabel,
|
||||
hint: l10n.phoneHint,
|
||||
enabled: !state.isSubmitting,
|
||||
onChanged: cubit.phoneChanged,
|
||||
onSubmitted: (_) => cubit.submitPhone(),
|
||||
errorText: state.phoneError == null
|
||||
? null
|
||||
: AuthFailureText.phoneMessageOf(context, state.phoneError!),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/di.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_text_field.dart';
|
||||
import '../cubit/profile_cubit.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
import 'widgets/auth_failure_text.dart';
|
||||
import 'widgets/auth_header.dart';
|
||||
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
final _name = TextEditingController();
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocProvider(
|
||||
create: (_) => ProfileCubit(sl()),
|
||||
child: BlocBuilder<ProfileCubit, ProfileState>(
|
||||
builder: (context, state) {
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.actionSave,
|
||||
loading: state.saving,
|
||||
onPressed: () => _submit(context),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
AuthHeader(
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: l10n.profileTitle,
|
||||
lead: l10n.profileLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzTextField(
|
||||
controller: _name,
|
||||
label: l10n.profileNameLabel,
|
||||
autofocus: true,
|
||||
enabled: !state.saving,
|
||||
errorText: _error,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofillHints: const [AutofillHints.name],
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit(BuildContext context) async {
|
||||
if (_name.text.trim().isEmpty) {
|
||||
setState(() => _error = context.l10n.profileNameErrEmpty);
|
||||
return;
|
||||
}
|
||||
final session = context.read<SessionCubit>();
|
||||
final user = await context.read<ProfileCubit>().save(_name.text);
|
||||
if (user != null) session.onProfileCompleted(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/design/tokens.dart';
|
||||
import '../../../../core/design/tripz_colors.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../cubit/auth_failure.dart';
|
||||
import '../../cubit/phone_error.dart';
|
||||
|
||||
/// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ.
|
||||
/// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`).
|
||||
class AuthFailureText extends StatelessWidget {
|
||||
const AuthFailureText({super.key, required this.failure});
|
||||
|
||||
final AuthFailure failure;
|
||||
|
||||
static String messageOf(BuildContext context, AuthFailure failure) {
|
||||
final l10n = context.l10n;
|
||||
return switch (failure) {
|
||||
AuthFailure.invalidCode => l10n.otpErrInvalid,
|
||||
AuthFailure.tooManyAttempts => l10n.otpErrTooManyAttempts,
|
||||
AuthFailure.rateLimited => l10n.otpErrRateLimited,
|
||||
AuthFailure.network => l10n.errorNetwork,
|
||||
AuthFailure.timeout => l10n.errorTimeout,
|
||||
AuthFailure.server => l10n.errorGeneric,
|
||||
};
|
||||
}
|
||||
|
||||
static String phoneMessageOf(BuildContext context, PhoneError error) {
|
||||
final l10n = context.l10n;
|
||||
return switch (error) {
|
||||
PhoneError.empty => l10n.phoneErrEmpty,
|
||||
PhoneError.leadingZero => l10n.phoneErrLeadingZero,
|
||||
PhoneError.tooShort => l10n.phoneErrShort,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Space.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: Sizes.iconSm,
|
||||
color: context.tripzColors.danger,
|
||||
),
|
||||
const SizedBox(width: Space.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageOf(context, failure),
|
||||
style: context.texts.bodySmall?.copyWith(
|
||||
color: context.tripzColors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/design/tokens.dart';
|
||||
import '../../../../core/design/tripz_colors.dart';
|
||||
|
||||
/// ترويسة موحّدة لشاشات الدخول: أيقونة ثم عنوان ثم سطر شارح.
|
||||
/// «الشاشة مهمة واحدة» — سؤال واحد وزر أساسي واحد أسفلها (docs/26 §8.1).
|
||||
class AuthHeader extends StatelessWidget {
|
||||
const AuthHeader({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.lead,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String lead;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Space.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
borderRadius: Radii.card_,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: Sizes.icon,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(title, style: context.texts.headlineSmall),
|
||||
const SizedBox(height: Space.xs),
|
||||
Text(
|
||||
lead,
|
||||
style: context.texts.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/design/tripz_colors.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/ui/tripz_card.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
import '../../../core/session/session_state.dart';
|
||||
|
||||
/// هيكل مؤقّت — تحلّ محلّه شاشة الخريطة وطلب الرحلة في م4 (docs/37).
|
||||
/// الغرض الآن: إثبات أن سلسلة الدخول والجلسة والتوجيه تعمل طرفاً لطرف.
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SessionCubit, SessionState>(
|
||||
builder: (context, session) {
|
||||
final user = session.user;
|
||||
return TripzScaffold(
|
||||
title: context.l10n.appTitle,
|
||||
child: ListView(
|
||||
children: [
|
||||
TripzCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user?.name ?? '',
|
||||
style: context.texts.titleMedium,
|
||||
),
|
||||
const SizedBox(height: Space.xxs),
|
||||
Text(
|
||||
user?.phone ?? '',
|
||||
textDirection: TextDirection.ltr,
|
||||
style: context.texts.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzButton.secondary(
|
||||
label: context.l10n.actionCancel,
|
||||
expanded: true,
|
||||
onPressed: context.read<SessionCubit>().logout,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'settings_state.dart';
|
||||
|
||||
class SettingsCubit extends Cubit<SettingsState> {
|
||||
SettingsCubit(this._prefs) : super(_read(_prefs));
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kTheme = 'settings.theme_mode';
|
||||
static const _kLocale = 'settings.locale';
|
||||
|
||||
/// تُقرأ **متزامنة** في الباني: تأخير القراءة يعني ومضة بالثيم الخاطئ عند
|
||||
/// كل إقلاع.
|
||||
static SettingsState _read(SharedPreferences prefs) {
|
||||
return SettingsState(
|
||||
themeMode: switch (prefs.getString(_kTheme)) {
|
||||
'light' => ThemeMode.light,
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system,
|
||||
},
|
||||
locale: Locale(prefs.getString(_kLocale) ?? 'ar'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
emit(state.copyWith(themeMode: mode));
|
||||
await _prefs.setString(_kTheme, mode.name);
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
emit(state.copyWith(locale: locale));
|
||||
await _prefs.setString(_kLocale, locale.languageCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// المظهر واللغة (docs/26 §4) — محفوظان محلياً، وليسا سرّاً فيذهبان إلى
|
||||
/// `SharedPreferences` لا إلى التخزين الآمن.
|
||||
class SettingsState extends Equatable {
|
||||
const SettingsState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.locale = const Locale('ar'),
|
||||
});
|
||||
|
||||
final ThemeMode themeMode;
|
||||
final Locale locale;
|
||||
|
||||
SettingsState copyWith({ThemeMode? themeMode, Locale? locale}) {
|
||||
return SettingsState(
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
locale: locale ?? this.locale,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [themeMode, locale];
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
|
||||
/// شاشة الإقلاع. في م3 تتولّى استعادة الجلسة والتوجيه؛ الآن تثبت فقط أن
|
||||
/// السقالة والأصول والخطوط تعمل (بوابة م1).
|
||||
class SplashPage extends StatelessWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Tripz',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppConfig.appRole == 'driver' ? 'تطبيق السائق' : 'تطبيق الراكب',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/build_config.dart';
|
||||
import '../../../core/config.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/design/tripz_colors.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
|
||||
/// شاشة الإقلاع: تستعيد الجلسة ثم يتولّى الراوتر التوجيه. لا تنقّل من هنا —
|
||||
/// قرار الوجهة في مكان واحد (`core/router.dart`).
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// بعد أول إطار: `restore` قد يُصدر حالة فوراً، وإصدارها أثناء البناء خطأ.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<SessionCubit>().restore();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(BuildConfig.logoAsset, width: 140, height: 140),
|
||||
const SizedBox(height: Space.lg),
|
||||
Text(
|
||||
BuildConfig.appName,
|
||||
style: context.texts.headlineMedium?.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Space.xs),
|
||||
Text(
|
||||
AppConfig.appRole == 'driver'
|
||||
? context.l10n.splashDriver
|
||||
: context.l10n.splashRider,
|
||||
style: context.texts.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Space.xl),
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await setupInjector();
|
||||
runApp(const TripzApp());
|
||||
runApp(
|
||||
// الجلسة فوق الجميع: الراوتر والشاشات يقرآنها من نقطة واحدة.
|
||||
BlocProvider.value(value: sl<SessionCubit>(), child: const TripzApp()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -43,3 +43,6 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# مُولَّد من ARB — لا يُحرَّر
|
||||
lib/core/l10n/gen/
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import 'core/build_config.dart';
|
||||
import 'core/design/theme.dart';
|
||||
import 'core/di.dart';
|
||||
import 'core/l10n/l10n.dart';
|
||||
import 'core/router.dart';
|
||||
import 'features/settings/cubit/settings_cubit.dart';
|
||||
import 'features/settings/cubit/settings_state.dart';
|
||||
|
||||
class TripzApp extends StatelessWidget {
|
||||
const TripzApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => sl<SettingsCubit>(),
|
||||
child: BlocBuilder<SettingsCubit, SettingsState>(
|
||||
builder: (context, settings) {
|
||||
return MaterialApp.router(
|
||||
title: BuildConfig.appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: appRouter,
|
||||
|
||||
// الثيم يُبنى من مدخلين لا ثالث لهما (docs/26 §4).
|
||||
theme: buildTheme(Brightness.light, settings.locale),
|
||||
darkTheme: buildTheme(Brightness.dark, settings.locale),
|
||||
themeMode: settings.themeMode,
|
||||
|
||||
// الاتجاه يتبع اللغة آلياً — لا `Directionality` مفروضة فوق كل
|
||||
// شيء، وإلا انكسرت الإنجليزية (docs/26 §4).
|
||||
locale: settings.locale,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
...L10n.localizationsDelegates,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
|
||||
// سقف تكبير النص 1.3 — وهو ما تُفحص عليه الشاشات (docs/26 §9).
|
||||
// ما فوقه يكسر التخطيط بلا مكسب قرائي.
|
||||
builder: (context, child) => MediaQuery.withClampedTextScaling(
|
||||
maxScaleFactor: 1.3,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/splash/splash_page.dart';
|
||||
|
||||
/// جدول التوجيه — يمتدّ في م3 (المصادقة) وم4 (الخريطة).
|
||||
class AppRoutes {
|
||||
const AppRoutes._();
|
||||
static const splash = '/';
|
||||
}
|
||||
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: AppRoutes.splash,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
+1
-1
@@ -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';
|
||||
+4
-3
@@ -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,
|
||||
}),
|
||||
@@ -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;
|
||||
}
|
||||
+13
-12
@@ -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);
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../build_config.dart';
|
||||
import 'tokens.dart';
|
||||
import 'tripz_colors.dart';
|
||||
import 'typography.dart';
|
||||
|
||||
/// دالة بناء الثيم الوحيدة (docs/26 §4): مدخلان لا ثالث لهما.
|
||||
///
|
||||
/// الاتجاه (RTL/LTR) **لا يُفرض هنا** — `MaterialApp` يشتقّه من الـlocale
|
||||
/// وحده. فرض `Directionality.rtl` يكسر الإنجليزية (docs/26 §4).
|
||||
ThemeData buildTheme(Brightness brightness, Locale locale) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: BuildConfig.seedColor,
|
||||
brightness: brightness,
|
||||
);
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final texts = buildTextTheme(brightness, locale);
|
||||
final tripz = isDark ? TripzColors.dark : TripzColors.light;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
brightness: brightness,
|
||||
fontFamily: AppFonts.familyFor(locale),
|
||||
textTheme: texts,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
extensions: [tripz],
|
||||
|
||||
// AppBar مسطّح بلا ظل (docs/26 §6).
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: texts.titleLarge?.copyWith(color: scheme.onSurface),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: tripz.surfaceRaised,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: Radii.card_,
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: Radii.sheetTop),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: isDark ? scheme.surfaceContainerHigh : scheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.md,
|
||||
vertical: Space.sm,
|
||||
),
|
||||
border: _fieldBorder(scheme.outlineVariant),
|
||||
enabledBorder: _fieldBorder(scheme.outlineVariant),
|
||||
focusedBorder: _fieldBorder(scheme.primary, width: 1.5),
|
||||
errorBorder: _fieldBorder(scheme.error),
|
||||
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
|
||||
errorStyle: texts.bodySmall?.copyWith(color: scheme.error),
|
||||
),
|
||||
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
contentTextStyle:
|
||||
texts.bodyMedium?.copyWith(color: scheme.onInverseSurface),
|
||||
shape: const RoundedRectangleBorder(borderRadius: Radii.card_),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) {
|
||||
return OutlineInputBorder(
|
||||
borderRadius: Radii.field_,
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// الرموز الثابتة (docs/26 §3). **الشاشة لا تخترع رقماً** — لا مسافة ولا زاوية
|
||||
/// ولا مدّة خارج هذا الملف.
|
||||
class Space {
|
||||
const Space._();
|
||||
|
||||
/// سلّم مضاعفات الأربعة.
|
||||
static const double xxs = 4;
|
||||
static const double xs = 8;
|
||||
static const double sm = 12;
|
||||
|
||||
/// حشوة الصفحة الافتراضية.
|
||||
static const double md = 16;
|
||||
static const double lg = 24;
|
||||
static const double xl = 32;
|
||||
static const double xxl = 48;
|
||||
|
||||
static const EdgeInsets page = EdgeInsets.all(md);
|
||||
static const EdgeInsets pageH = EdgeInsets.symmetric(horizontal: md);
|
||||
}
|
||||
|
||||
class Radii {
|
||||
const Radii._();
|
||||
|
||||
/// حقول وشارات.
|
||||
static const double field = 8;
|
||||
|
||||
/// أزرار وبطاقات.
|
||||
static const double card = 12;
|
||||
|
||||
/// الأوراق السفلية.
|
||||
static const double sheet = 24;
|
||||
|
||||
static const BorderRadius field_ = BorderRadius.all(Radius.circular(field));
|
||||
static const BorderRadius card_ = BorderRadius.all(Radius.circular(card));
|
||||
static const BorderRadius sheetTop = BorderRadius.vertical(
|
||||
top: Radius.circular(sheet),
|
||||
);
|
||||
}
|
||||
|
||||
class Motion {
|
||||
const Motion._();
|
||||
|
||||
/// استجابة لمسة.
|
||||
static const Duration tap = Duration(milliseconds: 120);
|
||||
|
||||
/// انتقال ضمن الشاشة.
|
||||
static const Duration inScreen = Duration(milliseconds: 240);
|
||||
|
||||
/// ورقة سفلية أو صفحة.
|
||||
static const Duration page = Duration(milliseconds: 400);
|
||||
|
||||
static const Curve curve = Curves.easeOutCubic;
|
||||
}
|
||||
|
||||
class Sizes {
|
||||
const Sizes._();
|
||||
|
||||
static const double iconSm = 20;
|
||||
static const double icon = 24;
|
||||
static const double iconLg = 32;
|
||||
|
||||
/// ارتفاع زر الـCTA وحقل الإدخال.
|
||||
static const double control = 52;
|
||||
|
||||
/// أدنى هدف لمس مقبول.
|
||||
static const double touchTarget = 48;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2).
|
||||
///
|
||||
/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها،
|
||||
/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر
|
||||
/// كل المستأجرين كما يفرض عقد §0.
|
||||
@immutable
|
||||
class TripzColors extends ThemeExtension<TripzColors> {
|
||||
const TripzColors({
|
||||
required this.success,
|
||||
required this.onSuccess,
|
||||
required this.warning,
|
||||
required this.onWarning,
|
||||
required this.danger,
|
||||
required this.onDanger,
|
||||
required this.info,
|
||||
required this.surfaceRaised,
|
||||
required this.mapRoute,
|
||||
required this.mapOrigin,
|
||||
required this.mapDestination,
|
||||
required this.skeleton,
|
||||
});
|
||||
|
||||
final Color success;
|
||||
final Color onSuccess;
|
||||
final Color warning;
|
||||
final Color onWarning;
|
||||
final Color danger;
|
||||
final Color onDanger;
|
||||
final Color info;
|
||||
|
||||
/// السطح المرتفع — في الليلي ارتفاعٌ **بالسطوع لا بالظل** (docs/26 §2).
|
||||
final Color surfaceRaised;
|
||||
|
||||
final Color mapRoute;
|
||||
final Color mapOrigin;
|
||||
final Color mapDestination;
|
||||
|
||||
/// لون هيكل التحميل (skeleton) — لا spinner فارغ (docs/26 §8.4).
|
||||
final Color skeleton;
|
||||
|
||||
static const light = TripzColors(
|
||||
success: Color(0xFF1B8A5A),
|
||||
onSuccess: Color(0xFFFFFFFF),
|
||||
warning: Color(0xFFB4690E),
|
||||
onWarning: Color(0xFFFFFFFF),
|
||||
danger: Color(0xFFC5303B),
|
||||
onDanger: Color(0xFFFFFFFF),
|
||||
info: Color(0xFF2563A8),
|
||||
surfaceRaised: Color(0xFFFFFFFF),
|
||||
mapRoute: Color(0xFF1F6FEB),
|
||||
mapOrigin: Color(0xFF1B8A5A),
|
||||
mapDestination: Color(0xFFC5303B),
|
||||
skeleton: Color(0xFFE6E8EC),
|
||||
);
|
||||
|
||||
static const dark = TripzColors(
|
||||
success: Color(0xFF4ECB8D),
|
||||
onSuccess: Color(0xFF00281A),
|
||||
warning: Color(0xFFE9A63B),
|
||||
onWarning: Color(0xFF2A1A00),
|
||||
danger: Color(0xFFF2707A),
|
||||
onDanger: Color(0xFF33000A),
|
||||
info: Color(0xFF7FB2F0),
|
||||
// ليس أسود صرفاً — رمادي داكن متدرّج.
|
||||
surfaceRaised: Color(0xFF20232A),
|
||||
mapRoute: Color(0xFF5B9BFF),
|
||||
mapOrigin: Color(0xFF4ECB8D),
|
||||
mapDestination: Color(0xFFF2707A),
|
||||
skeleton: Color(0xFF2C3038),
|
||||
);
|
||||
|
||||
@override
|
||||
TripzColors copyWith({
|
||||
Color? success,
|
||||
Color? onSuccess,
|
||||
Color? warning,
|
||||
Color? onWarning,
|
||||
Color? danger,
|
||||
Color? onDanger,
|
||||
Color? info,
|
||||
Color? surfaceRaised,
|
||||
Color? mapRoute,
|
||||
Color? mapOrigin,
|
||||
Color? mapDestination,
|
||||
Color? skeleton,
|
||||
}) {
|
||||
return TripzColors(
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
warning: warning ?? this.warning,
|
||||
onWarning: onWarning ?? this.onWarning,
|
||||
danger: danger ?? this.danger,
|
||||
onDanger: onDanger ?? this.onDanger,
|
||||
info: info ?? this.info,
|
||||
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
|
||||
mapRoute: mapRoute ?? this.mapRoute,
|
||||
mapOrigin: mapOrigin ?? this.mapOrigin,
|
||||
mapDestination: mapDestination ?? this.mapDestination,
|
||||
skeleton: skeleton ?? this.skeleton,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TripzColors lerp(ThemeExtension<TripzColors>? other, double t) {
|
||||
if (other is! TripzColors) return this;
|
||||
return TripzColors(
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
|
||||
warning: Color.lerp(warning, other.warning, t)!,
|
||||
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
|
||||
danger: Color.lerp(danger, other.danger, t)!,
|
||||
onDanger: Color.lerp(onDanger, other.onDanger, t)!,
|
||||
info: Color.lerp(info, other.info, t)!,
|
||||
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
|
||||
mapRoute: Color.lerp(mapRoute, other.mapRoute, t)!,
|
||||
mapOrigin: Color.lerp(mapOrigin, other.mapOrigin, t)!,
|
||||
mapDestination: Color.lerp(mapDestination, other.mapDestination, t)!,
|
||||
skeleton: Color.lerp(skeleton, other.skeleton, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension TripzColorsX on BuildContext {
|
||||
TripzColors get tripzColors => Theme.of(this).extension<TripzColors>()!;
|
||||
ColorScheme get colors => Theme.of(this).colorScheme;
|
||||
TextTheme get texts => Theme.of(this).textTheme;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// الخطوط (docs/26 §1) — ملفات محلية في `assets/fonts/`. حزمة `google_fonts`
|
||||
/// ممنوعة: الشكل يجب أن يكون حتمياً من أول إقلاع بلا شبكة.
|
||||
class AppFonts {
|
||||
const AppFonts._();
|
||||
|
||||
static const arabic = 'IBMPlexSansArabic';
|
||||
static const latin = 'Inter';
|
||||
|
||||
/// الخط يتبع اللغة، والآخر احتياط — كي لا تنكسر كلمة عربية داخل جملة
|
||||
/// إنجليزية ولا العكس.
|
||||
static String familyFor(Locale locale) =>
|
||||
locale.languageCode == 'ar' ? arabic : latin;
|
||||
|
||||
static List<String> fallbackFor(Locale locale) =>
|
||||
locale.languageCode == 'ar' ? const [latin] : const [arabic];
|
||||
}
|
||||
|
||||
/// أرقام tabular: تمنع «رقص» الأعمدة حين تتغيّر القيمة — للأسعار والعدّادات
|
||||
/// والمسافات الحيّة (docs/26 §1). يُغني عن خط `digit` المتقاعد.
|
||||
const tabularFigures = [FontFeature.tabularFigures()];
|
||||
|
||||
TextTheme buildTextTheme(Brightness brightness, Locale locale) {
|
||||
final base = brightness == Brightness.dark
|
||||
? Typography.material2021().white
|
||||
: Typography.material2021().black;
|
||||
|
||||
final family = AppFonts.familyFor(locale);
|
||||
final fallback = AppFonts.fallbackFor(locale);
|
||||
|
||||
return base
|
||||
.apply(fontFamily: family, fontFamilyFallback: fallback)
|
||||
.copyWith(
|
||||
displaySmall: base.displaySmall?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
headlineMedium: base.headlineMedium?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
headlineSmall: base.headlineSmall?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleLarge: base.titleLarge?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleMedium: base.titleMedium?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
labelLarge: base.labelLarge?.copyWith(
|
||||
fontFamily: family,
|
||||
fontFamilyFallback: fallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// نمط الأرقام الحيّة — يُستعمل للأجرة والرصيد والعدّاد التنازلي.
|
||||
/// دائماً Inter بأرقام tabular، مهما كانت لغة الواجهة (docs/26 §5: الأرقام
|
||||
/// لاتينية في اللغتين).
|
||||
TextStyle numericStyle(TextStyle? base) => (base ?? const TextStyle()).copyWith(
|
||||
fontFamily: AppFonts.latin,
|
||||
fontFeatures: tabularFigures,
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../features/auth/cubit/login_cubit.dart';
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
import '../features/settings/cubit/settings_cubit.dart';
|
||||
import 'api/api_client.dart';
|
||||
import 'session/session_cubit.dart';
|
||||
import 'storage/token_store.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// نقطة الإقفال الوحيدة (docs/23 §5): الميزة المطفأة **لا تُسجَّل**، بدل
|
||||
/// `if` مبعثرة في الشاشات.
|
||||
Future<void> setupInjector() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
sl.registerSingleton<SharedPreferences>(prefs);
|
||||
|
||||
const secure = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final tokens = TokenStore(secure);
|
||||
await tokens.load();
|
||||
await _ensureDeviceId(tokens);
|
||||
sl.registerSingleton<TokenStore>(tokens);
|
||||
|
||||
sl.registerSingleton<SettingsCubit>(SettingsCubit(prefs));
|
||||
|
||||
// انتهاء الجلسة يصعد من طبقة الشبكة إلى `SessionCubit` مباشرة — الراوتر
|
||||
// يستمع إليه فيخرج المستخدم بلا `BuildContext` في الـinterceptor.
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient.create(
|
||||
tokens: tokens,
|
||||
onSessionExpired: () async => sl<SessionCubit>().onSessionExpired(),
|
||||
),
|
||||
);
|
||||
|
||||
sl.registerSingleton<AuthRepository>(AuthRepository(sl(), sl()));
|
||||
sl.registerSingleton<SessionCubit>(SessionCubit(sl()));
|
||||
|
||||
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
|
||||
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
|
||||
}
|
||||
|
||||
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
|
||||
/// `AUTH_REQUIRE_DEVICE_BINDING` (docs/38 §8). تُرسَل من الآن كي يُفعَّل
|
||||
/// العَلَم لاحقاً بلا تعديل التطبيق: **أرسل أولاً ثم فعّل الخادم**.
|
||||
Future<void> _ensureDeviceId(TokenStore tokens) async {
|
||||
if (await tokens.deviceId() != null) return;
|
||||
final info = DeviceInfoPlugin();
|
||||
String? id;
|
||||
try {
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
id = (await info.androidInfo).id;
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
id = (await info.iosInfo).identifierForVendor;
|
||||
}
|
||||
} catch (_) {
|
||||
// منصّة لا تجيب — المعرّف الفارغ أفضل من إقلاع فاشل.
|
||||
}
|
||||
if (id != null && id.isNotEmpty) await tokens.setDeviceId(id);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// يُنادى مرة واحدة قبل `runApp`.
|
||||
Future<void> setupInjector() async {
|
||||
sl.registerSingleton<SharedPreferences>(
|
||||
await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
const secure = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
final tokens = TokenStore(secure);
|
||||
await tokens.load();
|
||||
sl.registerSingleton<TokenStore>(tokens);
|
||||
|
||||
sl.registerSingleton<ApiClient>(
|
||||
ApiClient.create(
|
||||
tokens: tokens,
|
||||
// يُربط بالتوجيه في م3 (إخراج للمستخدم عند انتهاء الجلسة).
|
||||
onSessionExpired: () async {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'session/session_cubit.dart';
|
||||
import 'session/session_state.dart';
|
||||
import '../features/auth/view/login_page.dart';
|
||||
import '../features/auth/view/profile_page.dart';
|
||||
import '../features/home/view/home_page.dart';
|
||||
import '../features/splash/view/splash_page.dart';
|
||||
import 'di.dart';
|
||||
|
||||
class Routes {
|
||||
const Routes._();
|
||||
static const splash = '/';
|
||||
static const login = '/login';
|
||||
static const profile = '/profile';
|
||||
static const home = '/home';
|
||||
}
|
||||
|
||||
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
||||
/// موزّعة في الشاشات، فلا تتناقض حالتان.
|
||||
final appRouter = GoRouter(
|
||||
initialLocation: Routes.splash,
|
||||
refreshListenable: _CubitListenable(sl<SessionCubit>().stream),
|
||||
redirect: (context, state) {
|
||||
final session = sl<SessionCubit>().state;
|
||||
final loc = state.matchedLocation;
|
||||
|
||||
// قبل أن نعرف: نبقى على الإقلاع ولا نقرّر شيئاً.
|
||||
if (!session.isReady) return loc == Routes.splash ? null : Routes.splash;
|
||||
|
||||
return switch (session.status) {
|
||||
SessionStatus.unauthenticated =>
|
||||
loc == Routes.login ? null : Routes.login,
|
||||
SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile,
|
||||
SessionStatus.authenticated =>
|
||||
(loc == Routes.login || loc == Routes.profile || loc == Routes.splash)
|
||||
? Routes.home
|
||||
: null,
|
||||
SessionStatus.unknown => Routes.splash,
|
||||
};
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.login,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.profile,
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.home,
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// جسر بين تيّار الـCubit و`refreshListenable` الذي يطلبه go_router.
|
||||
class _CubitListenable extends ChangeNotifier {
|
||||
_CubitListenable(Stream<dynamic> stream) {
|
||||
_sub = stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// المستخدم كما يرجّعه الخادم (docs/38 §2 — شكل مُتحقَّق حيّاً).
|
||||
class AppUser extends Equatable {
|
||||
const AppUser({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.phone,
|
||||
required this.role,
|
||||
required this.status,
|
||||
required this.language,
|
||||
this.name,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String tenantId;
|
||||
|
||||
/// مطبَّع دولياً من الخادم (`0790000001` ← `962790000001`) — لا يُقارن نصّياً
|
||||
/// بما أدخله المستخدم (docs/38 §2).
|
||||
final String phone;
|
||||
|
||||
final String role;
|
||||
final String status;
|
||||
final String language;
|
||||
final String? name;
|
||||
|
||||
/// نصّ لا رقم — كل الأرقام العشرية تصل نصوصاً (docs/38 §12.2).
|
||||
final String? rating;
|
||||
|
||||
/// `phone_bidx` يصل في الرد لكنه فهرس أعمى للخادم — **لا يُخزَّن ولا يُعرض**.
|
||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||
return AppUser(
|
||||
id: json['id'] as String,
|
||||
tenantId: json['tenant_id'] as String,
|
||||
phone: json['phone'] as String,
|
||||
role: json['role'] as String? ?? 'rider',
|
||||
status: json['status'] as String? ?? 'active',
|
||||
language: json['language'] as String? ?? 'ar',
|
||||
name: json['name'] as String?,
|
||||
rating: json['rating']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// الملف ناقص ما لم يُدخل المستخدم اسمه — يقود شاشة إكمال الملف.
|
||||
bool get needsProfile => name == null || name!.trim().isEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, tenantId, phone, role, status, language, name];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../api/api_exception.dart';
|
||||
|
||||
import 'app_user.dart';
|
||||
import 'session_source.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
/// حالة الجلسة على مستوى التطبيق — مصدر قرار التوجيه الوحيد
|
||||
/// (`core/router.dart` يستمع إليها).
|
||||
class SessionCubit extends Cubit<SessionState> {
|
||||
SessionCubit(this._repo) : super(const SessionState());
|
||||
|
||||
final SessionSource _repo;
|
||||
|
||||
/// تُستدعى مرة عند الإقلاع من شاشة الـsplash.
|
||||
Future<void> restore() async {
|
||||
if (!_repo.hasSession) {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_apply(await _repo.me());
|
||||
} on ApiException catch (e) {
|
||||
// 401 هنا يعني رمز تحديث ميّت — الـinterceptor مسح المخزن أصلاً.
|
||||
// أي خطأ آخر (شبكة) لا يُسقط الجلسة: المستخدم مسجّل، الشبكة هي الغائبة.
|
||||
if (e.isUnauthorized) {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
} else {
|
||||
emit(const SessionState(status: SessionStatus.authenticated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onLoggedIn(AppUser user) => _apply(user);
|
||||
|
||||
void onProfileCompleted(AppUser user) => _apply(user);
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repo.logout();
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
}
|
||||
|
||||
/// انتهاء الجلسة من طبقة الشبكة (رمز تحديث ميّت) — لا يمرّ بالمستودع.
|
||||
void onSessionExpired() {
|
||||
emit(const SessionState(status: SessionStatus.unauthenticated));
|
||||
}
|
||||
|
||||
void _apply(AppUser user) {
|
||||
emit(SessionState(
|
||||
status: user.needsProfile
|
||||
? SessionStatus.needsProfile
|
||||
: SessionStatus.authenticated,
|
||||
user: user,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'app_user.dart';
|
||||
|
||||
/// ما تحتاجه الجلسة من طبقة المصادقة — **ولا شيء أكثر**.
|
||||
///
|
||||
/// `SessionCubit` حالةٌ على مستوى التطبيق (الراوتر والشاشات كلها تقرأها)،
|
||||
/// فمحلّها `core/`. ولأن `core/` لا يجوز أن يستورد من `features/` كما لا
|
||||
/// تستورد ميزةٌ من أخرى (docs/23 §1)، تُعرّف الحاجة هنا كواجهة ضيّقة
|
||||
/// ويُنفّذها `AuthRepository` في ميزة المصادقة.
|
||||
abstract interface class SessionSource {
|
||||
Future<AppUser> me();
|
||||
Future<void> logout();
|
||||
bool get hasSession;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'app_user.dart';
|
||||
|
||||
enum SessionStatus {
|
||||
/// قبل أن نعرف — تُعرض شاشة الإقلاع، ولا يُتّخذ قرار توجيه.
|
||||
unknown,
|
||||
authenticated,
|
||||
|
||||
/// مصادَق لكن بلا اسم — يُوجَّه لإكمال الملف قبل أي شاشة أخرى.
|
||||
needsProfile,
|
||||
unauthenticated,
|
||||
}
|
||||
|
||||
class SessionState extends Equatable {
|
||||
const SessionState({this.status = SessionStatus.unknown, this.user});
|
||||
|
||||
final SessionStatus status;
|
||||
final AppUser? user;
|
||||
|
||||
bool get isReady => status != SessionStatus.unknown;
|
||||
|
||||
SessionState copyWith({SessionStatus? status, AppUser? user}) {
|
||||
return SessionState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, user];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// سمة مبدئية — تُستبدل بنظام التصميم الكامل في م2 (docs/26).
|
||||
/// الغرض الآن: تثبيت الخطوط وأن يقلع التطبيق بشكل صحيح، لا أكثر.
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static const seed = Color(0xFF1F6FEB);
|
||||
static const arabicFont = 'IBMPlexSansArabic';
|
||||
|
||||
static ThemeData light() => _base(Brightness.light);
|
||||
static ThemeData dark() => _base(Brightness.dark);
|
||||
|
||||
static ThemeData _base(Brightness brightness) {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: arabicFont,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'tripz_button.dart';
|
||||
|
||||
/// حالة الفراغ: رسالة **تدلّ على الفعل التالي** لا مجرّد «لا يوجد»
|
||||
/// (docs/26 §8.4).
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_rounded,
|
||||
this.message,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String? message;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Space.page,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: Sizes.iconLg,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(
|
||||
message ?? context.l10n.emptyDefault,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.texts.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (onAction != null && actionLabel != null) ...[
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzButton.secondary(label: actionLabel!, onPressed: onAction),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'tripz_button.dart';
|
||||
|
||||
/// حالة الخطأ: رسالة **بالعربية** مع «أعد المحاولة» — لا شاشة صامتة
|
||||
/// (docs/26 §8.4). الرسالة تأتي جاهزة من الـCubit (docs/23 §3).
|
||||
class ErrorView extends StatelessWidget {
|
||||
const ErrorView({super.key, this.message, this.onRetry});
|
||||
|
||||
final String? message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: Space.page,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: Sizes.iconLg,
|
||||
color: context.tripzColors.danger,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(
|
||||
message ?? context.l10n.errorGeneric,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.texts.bodyMedium,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzButton.secondary(
|
||||
label: context.l10n.actionRetry,
|
||||
onPressed: onRetry,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
|
||||
/// هيكل تحميل — **لا spinner فارغ** (docs/26 §8.4).
|
||||
///
|
||||
/// النبض `AnimatedBuilder` على `Opacity` وحدها: لا يُعاد بناء الشجرة تحته،
|
||||
/// وهذا ما يفرق بين skeleton رخيص وآخر يأكل إطارات.
|
||||
class Skeleton extends StatefulWidget {
|
||||
const Skeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.radius = Radii.field,
|
||||
});
|
||||
|
||||
/// ثلاثة أسطر بأطوال متفاوتة — الشكل الافتراضي لأي قائمة تُحمَّل.
|
||||
const Skeleton.lines({super.key})
|
||||
: width = null,
|
||||
height = -1,
|
||||
radius = Radii.field;
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<Skeleton> createState() => _SkeletonState();
|
||||
}
|
||||
|
||||
class _SkeletonState extends State<Skeleton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = context.tripzColors.skeleton;
|
||||
|
||||
final child = widget.height < 0
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_bar(color, double.infinity),
|
||||
const SizedBox(height: Space.sm),
|
||||
_bar(color, 220),
|
||||
const SizedBox(height: Space.sm),
|
||||
_bar(color, 140),
|
||||
],
|
||||
)
|
||||
: _bar(color, widget.width ?? double.infinity, widget.height);
|
||||
|
||||
// تُحترم إعدادات تقليل الحركة في النظام (docs/26 §8.5).
|
||||
if (MediaQuery.disableAnimationsOf(context)) return child;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, inner) => Opacity(
|
||||
opacity: 0.45 + (_controller.value * 0.35),
|
||||
child: inner,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bar(Color color, double width, [double height = 16]) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/tripz_colors.dart';
|
||||
|
||||
enum BannerTone { info, success, warning, danger }
|
||||
|
||||
/// شريط الحالة الحيّ (docs/26 §7) — حالة الرحلة، تحذير الرصيد، انقطاع الشبكة.
|
||||
/// اللون من الأدوار الدلالية لا من hex.
|
||||
class StatusBanner extends StatelessWidget {
|
||||
const StatusBanner({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.tone = BannerTone.info,
|
||||
this.icon,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final BannerTone tone;
|
||||
final IconData? icon;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.tripzColors;
|
||||
final color = switch (tone) {
|
||||
BannerTone.info => c.info,
|
||||
BannerTone.success => c.success,
|
||||
BannerTone.warning => c.warning,
|
||||
BannerTone.danger => c.danger,
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.sm,
|
||||
vertical: Space.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: Radii.field_,
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon ?? Icons.info_outline_rounded, size: Sizes.iconSm, color: color),
|
||||
const SizedBox(width: Space.xs),
|
||||
Expanded(
|
||||
child: Text(message, style: context.texts.bodySmall),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
enum _ButtonKind { primary, secondary, text }
|
||||
|
||||
/// الزر الموحّد (docs/26 §7). ارتفاع 52، وحالة تحميل **داخلية**: الشاشة
|
||||
/// تمرّر `loading: true` ولا تستبدل الزر بمؤشّر — فلا يقفز التخطيط.
|
||||
class TripzButton extends StatelessWidget {
|
||||
const TripzButton.primary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = true,
|
||||
}) : _kind = _ButtonKind.primary;
|
||||
|
||||
const TripzButton.secondary({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = false,
|
||||
}) : _kind = _ButtonKind.secondary;
|
||||
|
||||
const TripzButton.text({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.expanded = false,
|
||||
}) : _kind = _ButtonKind.text;
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final IconData? icon;
|
||||
final bool expanded;
|
||||
final _ButtonKind _kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// أثناء التحميل يُعطَّل الزر — يمنع الإرسال المزدوج بلا حارس في كل شاشة.
|
||||
final onTap = loading ? null : onPressed;
|
||||
final child = _Content(label: label, icon: icon, loading: loading);
|
||||
|
||||
final button = switch (_kind) {
|
||||
_ButtonKind.primary => FilledButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
_ButtonKind.secondary => OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
_ButtonKind.text => TextButton(
|
||||
onPressed: onTap,
|
||||
style: _style(context),
|
||||
child: child,
|
||||
),
|
||||
};
|
||||
|
||||
if (!expanded) return button;
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
|
||||
ButtonStyle _style(BuildContext context) => ButtonStyle(
|
||||
minimumSize: const WidgetStatePropertyAll(
|
||||
Size(0, Sizes.control),
|
||||
),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: Radii.card_),
|
||||
),
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: Space.lg),
|
||||
),
|
||||
textStyle: WidgetStatePropertyAll(
|
||||
Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Content extends StatelessWidget {
|
||||
const _Content({required this.label, this.icon, required this.loading});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool loading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (loading) {
|
||||
return const SizedBox(
|
||||
width: Sizes.iconSm,
|
||||
height: Sizes.iconSm,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.2),
|
||||
);
|
||||
}
|
||||
if (icon == null) return Text(label);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: Sizes.iconSm),
|
||||
const SizedBox(width: Space.xs),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// بطاقة موحّدة (docs/26 §7): الرحلة · السائق · المحفظة.
|
||||
/// الحدّ لا الظل — «الوضوح قبل الزينة» (docs/26 §8.2).
|
||||
class TripzCard extends StatelessWidget {
|
||||
const TripzCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.padding = Space.page,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Padding(padding: padding, child: child);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: onTap == null
|
||||
? content
|
||||
: InkWell(onTap: onTap, child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/typography.dart';
|
||||
|
||||
/// حقل رمز التحقّق — أربع خانات (طول رمز تريبز، docs/38 §2).
|
||||
///
|
||||
/// حقل حقيقي واحد تحت خانات معروضة: يُبقي اللصق والإكمال التلقائي من الرسالة
|
||||
/// (`AutofillHints.oneTimeCode`) يعملان — وهو ما يكسره تقسيمُه إلى أربعة
|
||||
/// حقول منفصلة. يُستدعى [onCompleted] عند اكتمال الطول.
|
||||
class TripzOtpField extends StatefulWidget {
|
||||
const TripzOtpField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.length = 4,
|
||||
this.onCompleted,
|
||||
this.enabled = true,
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final int length;
|
||||
final ValueChanged<String>? onCompleted;
|
||||
final bool enabled;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
State<TripzOtpField> createState() => _TripzOtpFieldState();
|
||||
}
|
||||
|
||||
class _TripzOtpFieldState extends State<TripzOtpField> {
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// الحقل الفعلي — شفّاف بلا حجم بصري، يستقبل الكيبورد واللصق.
|
||||
SizedBox(
|
||||
height: Sizes.control,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focus,
|
||||
enabled: widget.enabled,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: widget.length,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
onChanged: (v) {
|
||||
if (v.length == widget.length) widget.onCompleted?.call(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _focus.requestFocus(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, value, _) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
children: List.generate(widget.length, (i) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Space.xxs),
|
||||
child: _Box(
|
||||
char: i < value.text.length ? value.text[i] : null,
|
||||
focused: _focus.hasFocus && i == value.text.length,
|
||||
hasError: widget.hasError,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Box extends StatelessWidget {
|
||||
const _Box({
|
||||
required this.char,
|
||||
required this.focused,
|
||||
required this.hasError,
|
||||
});
|
||||
|
||||
final String? char;
|
||||
final bool focused;
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final borderColor = hasError
|
||||
? scheme.error
|
||||
: focused
|
||||
? scheme.primary
|
||||
: scheme.outlineVariant;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: Motion.tap,
|
||||
curve: Motion.curve,
|
||||
width: 56,
|
||||
height: Sizes.control,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: Radii.field_,
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: focused || hasError ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
char ?? '',
|
||||
style: numericStyle(Theme.of(context).textTheme.headlineSmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import 'empty_view.dart';
|
||||
import 'error_view.dart';
|
||||
import 'skeleton.dart';
|
||||
import 'view_status.dart';
|
||||
|
||||
/// نقطة التنظيم المركزية (docs/26 §6). **ممنوع `Scaffold` خام في
|
||||
/// `features/`.**
|
||||
///
|
||||
/// هو ما يحوّل `status` القادم من الـCubit إلى skeleton/خطأ/فراغ/محتوى — فلا
|
||||
/// تكتب أي شاشة `if (loading)`، وتغيير شكل «التحميل» في التطبيق كله يصير
|
||||
/// سطراً واحداً هنا.
|
||||
class TripzScaffold extends StatelessWidget {
|
||||
const TripzScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.status = ViewStatus.success,
|
||||
this.error,
|
||||
this.onRetry,
|
||||
this.empty,
|
||||
this.loading,
|
||||
this.bottomAction,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.showAppBar = true,
|
||||
this.padded = true,
|
||||
this.resizeToAvoidBottomInset = true,
|
||||
});
|
||||
|
||||
/// المحتوى — يُبنى **عند النجاح فقط**.
|
||||
final Widget child;
|
||||
|
||||
final String? title;
|
||||
final ViewStatus status;
|
||||
|
||||
/// رسالة الخطأ كما أصدرها الـCubit، بلا إعادة صياغة في الواجهة.
|
||||
final String? error;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// شكل الفراغ الخاص بالشاشة — وإلا فالافتراضي.
|
||||
final Widget? empty;
|
||||
|
||||
/// شكل التحميل الخاص بالشاشة — وإلا فهيكل ثلاثة أسطر.
|
||||
final Widget? loading;
|
||||
|
||||
/// خانة الـCTA السفلية: ترتفع فوق الكيبورد، وتحترم المنطقة الآمنة.
|
||||
final Widget? bottomAction;
|
||||
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool showAppBar;
|
||||
final bool padded;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
appBar: !showAppBar
|
||||
? null
|
||||
: AppBar(
|
||||
title: title == null ? null : Text(title!),
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
),
|
||||
body: SafeArea(
|
||||
top: !showAppBar,
|
||||
child: Padding(
|
||||
padding: padded ? Space.page : EdgeInsets.zero,
|
||||
child: _Body(
|
||||
status: status,
|
||||
error: error,
|
||||
onRetry: onRetry,
|
||||
empty: empty,
|
||||
loading: loading,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: bottomAction == null
|
||||
? null
|
||||
: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
// viewInsets يرفع الـCTA فوق الكيبورد بدل أن يختفي تحته.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Space.md,
|
||||
Space.xs,
|
||||
Space.md,
|
||||
Space.md + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: bottomAction,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.status,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.empty,
|
||||
required this.loading,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final String? error;
|
||||
final VoidCallback? onRetry;
|
||||
final Widget? empty;
|
||||
final Widget? loading;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (status) {
|
||||
ViewStatus.idle ||
|
||||
ViewStatus.loading =>
|
||||
loading ?? const Skeleton.lines(),
|
||||
ViewStatus.error => ErrorView(message: error, onRetry: onRetry),
|
||||
ViewStatus.empty => empty ?? const EmptyView(),
|
||||
ViewStatus.success => child,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// الورقة السفلية — **الحوار الافتراضي في التطبيق sheet لا dialog**
|
||||
/// (docs/26 §7). المقبض والزوايا من الثيم، فلا تُعاد كتابتهما في كل نداء.
|
||||
class TripzSheet extends StatelessWidget {
|
||||
const TripzSheet({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.title,
|
||||
this.bottomAction,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String? title;
|
||||
final Widget? bottomAction;
|
||||
|
||||
/// [dismissible] = false للحالات التي لا تُغلق بالسحب — مثل شاشة البحث عن
|
||||
/// سائق: تُغلق بزر الإلغاء وحده كي لا يزيحها المستخدم بالخطأ (docs/39 §3).
|
||||
static Future<T?> show<T>(
|
||||
BuildContext context, {
|
||||
required Widget child,
|
||||
String? title,
|
||||
Widget? bottomAction,
|
||||
bool dismissible = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: dismissible,
|
||||
enableDrag: dismissible,
|
||||
showDragHandle: dismissible,
|
||||
builder: (_) => TripzSheet(
|
||||
title: title,
|
||||
bottomAction: bottomAction,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Space.md,
|
||||
Space.xs,
|
||||
Space.md,
|
||||
Space.md + MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(title!, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: Space.md),
|
||||
],
|
||||
Flexible(child: child),
|
||||
if (bottomAction != null) ...[
|
||||
const SizedBox(height: Space.md),
|
||||
bottomAction!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../design/tokens.dart';
|
||||
import '../design/typography.dart';
|
||||
|
||||
/// حقل الإدخال الموحّد (docs/26 §7): نصّ · هاتف · بحث.
|
||||
///
|
||||
/// رسالة الخطأ تحته بالعربية، والأرقام لاتينية tabular دائماً — رقم الهاتف
|
||||
/// ورمز التحقّق لا «يرقصان» أثناء الكتابة.
|
||||
class TripzTextField extends StatelessWidget {
|
||||
const TripzTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.errorText,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.enabled = true,
|
||||
this.maxLength,
|
||||
this.prefixIcon,
|
||||
this.suffix,
|
||||
this.inputFormatters,
|
||||
this.autofillHints,
|
||||
this.numeric = false,
|
||||
});
|
||||
|
||||
/// حقل هاتف: أرقام فقط، لوحة مفاتيح هاتف، إكمال تلقائي من النظام.
|
||||
factory TripzTextField.phone({
|
||||
Key? key,
|
||||
TextEditingController? controller,
|
||||
String? label,
|
||||
String? hint,
|
||||
String? errorText,
|
||||
ValueChanged<String>? onChanged,
|
||||
ValueChanged<String>? onSubmitted,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return TripzTextField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
label: label,
|
||||
hint: hint,
|
||||
errorText: errorText,
|
||||
enabled: enabled,
|
||||
numeric: true,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
maxLength: 15,
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
autofillHints: const [AutofillHints.telephoneNumber],
|
||||
);
|
||||
}
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final String? errorText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
final bool enabled;
|
||||
final int? maxLength;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffix;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final Iterable<String>? autofillHints;
|
||||
final bool numeric;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.bodyLarge;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
autofocus: autofocus,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
maxLength: maxLength,
|
||||
inputFormatters: inputFormatters,
|
||||
autofillHints: autofillHints,
|
||||
style: numeric ? numericStyle(base) : base,
|
||||
// الأرقام تُقرأ يساراً-ليميناً حتى في واجهة عربية.
|
||||
textDirection: numeric ? TextDirection.ltr : null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: errorText,
|
||||
counterText: '',
|
||||
prefixIcon: prefixIcon == null
|
||||
? null
|
||||
: Icon(prefixIcon, size: Sizes.iconSm),
|
||||
suffixIcon: suffix,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// حالة العرض الموحّدة التي يُصدرها كل Cubit (docs/23 §3) ويستهلكها
|
||||
/// `TripzScaffold` (docs/26 §6).
|
||||
///
|
||||
/// هذا ما يجعل نظام التصميم متوافقاً مع Cubit **بالبنية لا بالاتفاق**:
|
||||
/// الشاشة لا تكتب `if (loading)` أبداً.
|
||||
enum ViewStatus {
|
||||
/// لم يبدأ شيء بعد — يُعرض كتحميل لأن المستخدم لا يفرّق.
|
||||
idle,
|
||||
loading,
|
||||
success,
|
||||
|
||||
/// نجح النداء ولا بيانات — يُعرض `EmptyView`.
|
||||
empty,
|
||||
error,
|
||||
}
|
||||
|
||||
extension ViewStatusX on ViewStatus {
|
||||
bool get isLoading => this == ViewStatus.idle || this == ViewStatus.loading;
|
||||
bool get isError => this == ViewStatus.error;
|
||||
bool get isEmpty => this == ViewStatus.empty;
|
||||
bool get isSuccess => this == ViewStatus.success;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../../../core/api/api_exception.dart';
|
||||
|
||||
/// سبب الفشل كـ**رمز** لا كنصّ.
|
||||
///
|
||||
/// docs/23 §3 يقول إن الـCubit يحوّل خطأ الشبكة إلى رسالة عربية؛ وdocs/26 §4
|
||||
/// (الأحدث، وهو الذي «يفعّل قاعدة docs/23 §2 التي كانت بلا أداة») يوجب مرور
|
||||
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
|
||||
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
|
||||
/// الشاشة بلا منطق، والنص بلغتين.
|
||||
enum AuthFailure {
|
||||
/// رمز خاطئ أو منتهي الصلاحية.
|
||||
invalidCode,
|
||||
|
||||
/// تجاوز عدد المحاولات — الخادم يُبطل الرمز فوراً (docs/38 §2).
|
||||
tooManyAttempts,
|
||||
|
||||
/// تجاوز حدّ طلب الرمز: ثلاثة كل خمس دقائق.
|
||||
rateLimited,
|
||||
|
||||
network,
|
||||
timeout,
|
||||
server,
|
||||
}
|
||||
|
||||
extension AuthFailureX on ApiException {
|
||||
AuthFailure toAuthFailure() {
|
||||
if (isRateLimited) return AuthFailure.rateLimited;
|
||||
if (kind == ApiErrorKind.network) return AuthFailure.network;
|
||||
if (kind == ApiErrorKind.timeout) return AuthFailure.timeout;
|
||||
if (isUnauthorized) {
|
||||
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
|
||||
return message.contains('Too many')
|
||||
? AuthFailure.tooManyAttempts
|
||||
: AuthFailure.invalidCode;
|
||||
}
|
||||
return AuthFailure.server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/config.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import 'auth_failure.dart';
|
||||
import 'login_state.dart';
|
||||
import 'phone_error.dart';
|
||||
|
||||
/// تدفّق الدخول كاملاً: الشروط ← إذن الموقع ← الهاتف ← الرمز.
|
||||
///
|
||||
/// الـCubit لا يعرف Flutter: لا `BuildContext` ولا تنقّل ولا `SnackBar`
|
||||
/// (docs/23 §3). يُصدر حالة، والواجهة تتصرّف.
|
||||
class LoginCubit extends Cubit<LoginState> {
|
||||
LoginCubit(this._repo, this._prefs) : super(const LoginState()) {
|
||||
_resolveInitialStep();
|
||||
}
|
||||
|
||||
final AuthRepository _repo;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kAgreed = 'auth.agreed_terms';
|
||||
|
||||
Timer? _resendTimer;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_resendTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
// ── البوابتان ──────────────────────────────────────────────────────────
|
||||
|
||||
void _resolveInitialStep() {
|
||||
if (_prefs.getBool(_kAgreed) != true) {
|
||||
emit(state.copyWith(step: LoginStep.agreement));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(step: LoginStep.permission, agreed: true));
|
||||
unawaited(checkLocationPermission());
|
||||
}
|
||||
|
||||
void toggleAgreement(bool value) => emit(state.copyWith(agreed: value));
|
||||
|
||||
Future<void> acceptAgreement() async {
|
||||
if (!state.agreed) return;
|
||||
await _prefs.setBool(_kAgreed, true);
|
||||
emit(state.copyWith(step: LoginStep.permission));
|
||||
await checkLocationPermission();
|
||||
}
|
||||
|
||||
/// تُستدعى أيضاً عند **العودة من إعدادات النظام**: بلا إعادة الفحص تبقى
|
||||
/// الشاشة عالقة بعد أن يمنح المستخدم الإذن يدوياً (حالة حافة من docs/39 §2).
|
||||
Future<void> checkLocationPermission() async {
|
||||
final status = await Permission.locationWhenInUse.status;
|
||||
if (status.isGranted || status.isLimited) {
|
||||
emit(state.copyWith(step: LoginStep.phone));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(
|
||||
permissionPermanentlyDenied: status.isPermanentlyDenied,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> requestLocationPermission() async {
|
||||
final status = await Permission.locationWhenInUse.request();
|
||||
if (status.isGranted || status.isLimited) {
|
||||
emit(state.copyWith(step: LoginStep.phone));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(
|
||||
permissionPermanentlyDenied: status.isPermanentlyDenied,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> openSystemSettings() => openAppSettings();
|
||||
|
||||
// ── الهاتف ─────────────────────────────────────────────────────────────
|
||||
|
||||
void phoneChanged(String value) {
|
||||
emit(state.copyWith(
|
||||
phone: value,
|
||||
clearPhoneError: true,
|
||||
clearFailure: true,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> submitPhone() async {
|
||||
final error = validatePhone(state.phone);
|
||||
if (error != null) {
|
||||
emit(state.copyWith(phoneError: error));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
await _repo.sendOtp(state.phone.trim());
|
||||
emit(state.copyWith(status: LoginStatus.idle, step: LoginStep.otp));
|
||||
_startResendCountdown();
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void editPhone() {
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
step: LoginStep.phone,
|
||||
status: LoginStatus.idle,
|
||||
resendIn: 0,
|
||||
clearFailure: true,
|
||||
));
|
||||
}
|
||||
|
||||
// ── الرمز ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> resendOtp() async {
|
||||
if (!state.canResend) return;
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
await _repo.sendOtp(state.phone.trim());
|
||||
emit(state.copyWith(status: LoginStatus.idle));
|
||||
_startResendCountdown();
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
|
||||
// فوراً وعمّق الحظر.
|
||||
_startResendCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitOtp(String code) async {
|
||||
if (code.length != AppConfig.otpLength) return;
|
||||
emit(state.copyWith(status: LoginStatus.submitting, clearFailure: true));
|
||||
try {
|
||||
final user = await _repo.verifyOtp(phone: state.phone.trim(), code: code);
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.success,
|
||||
step: LoginStep.done,
|
||||
user: user,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _startResendCountdown() {
|
||||
_resendTimer?.cancel();
|
||||
emit(state.copyWith(resendIn: AppConfig.otpResendCooldown.inSeconds));
|
||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
final next = state.resendIn - 1;
|
||||
if (next <= 0) timer.cancel();
|
||||
emit(state.copyWith(resendIn: next < 0 ? 0 : next));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../core/session/app_user.dart';
|
||||
import 'auth_failure.dart';
|
||||
import 'phone_error.dart';
|
||||
|
||||
/// خطوات الدخول بالترتيب الميداني المثبت في سيرو (docs/39 §2): بوابتان
|
||||
/// كاملتان **قبل** أي حقل إدخال، ثم الهاتف فالرمز.
|
||||
enum LoginStep { agreement, permission, phone, otp, done }
|
||||
|
||||
enum LoginStatus { idle, submitting, failure, success }
|
||||
|
||||
class LoginState extends Equatable {
|
||||
const LoginState({
|
||||
this.step = LoginStep.agreement,
|
||||
this.status = LoginStatus.idle,
|
||||
this.phone = '',
|
||||
this.agreed = false,
|
||||
this.failure,
|
||||
this.phoneError,
|
||||
this.resendIn = 0,
|
||||
this.permissionPermanentlyDenied = false,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final LoginStep step;
|
||||
final LoginStatus status;
|
||||
|
||||
/// الرقم كما أدخله المستخدم — الخادم يطبّعه، فلا نطبّعه نحن (docs/38 §2).
|
||||
final String phone;
|
||||
|
||||
final bool agreed;
|
||||
final AuthFailure? failure;
|
||||
final PhoneError? phoneError;
|
||||
|
||||
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
|
||||
/// خمس دقائق، فالعدّاد ليس تجميلاً (docs/39 §2).
|
||||
final int resendIn;
|
||||
|
||||
/// رُفض الإذن نهائياً — الحل الوحيد فتح إعدادات النظام.
|
||||
final bool permissionPermanentlyDenied;
|
||||
|
||||
final AppUser? user;
|
||||
|
||||
bool get isSubmitting => status == LoginStatus.submitting;
|
||||
bool get canResend => resendIn == 0 && !isSubmitting;
|
||||
|
||||
LoginState copyWith({
|
||||
LoginStep? step,
|
||||
LoginStatus? status,
|
||||
String? phone,
|
||||
bool? agreed,
|
||||
AuthFailure? failure,
|
||||
PhoneError? phoneError,
|
||||
int? resendIn,
|
||||
bool? permissionPermanentlyDenied,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
bool clearPhoneError = false,
|
||||
}) {
|
||||
return LoginState(
|
||||
step: step ?? this.step,
|
||||
status: status ?? this.status,
|
||||
phone: phone ?? this.phone,
|
||||
agreed: agreed ?? this.agreed,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
phoneError: clearPhoneError ? null : (phoneError ?? this.phoneError),
|
||||
resendIn: resendIn ?? this.resendIn,
|
||||
permissionPermanentlyDenied:
|
||||
permissionPermanentlyDenied ?? this.permissionPermanentlyDenied,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
step,
|
||||
status,
|
||||
phone,
|
||||
agreed,
|
||||
failure,
|
||||
phoneError,
|
||||
resendIn,
|
||||
permissionPermanentlyDenied,
|
||||
user,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// أخطاء تحقّق **محليّة** لرقم الهاتف — تُكتشف قبل أي نداء شبكة.
|
||||
/// الشروط الثلاثة منقولة من التحقّق المطبَّق ميدانياً في سيرو (docs/39 §2).
|
||||
enum PhoneError { empty, leadingZero, tooShort }
|
||||
|
||||
PhoneError? validatePhone(String raw) {
|
||||
final v = raw.trim();
|
||||
if (v.isEmpty) return PhoneError.empty;
|
||||
if (v.startsWith('0')) return PhoneError.leadingZero;
|
||||
if (v.length < 9) return PhoneError.tooShort;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/view_status.dart';
|
||||
import '../data/auth_repository.dart';
|
||||
import '../../../core/session/app_user.dart';
|
||||
import 'auth_failure.dart';
|
||||
|
||||
class ProfileState {
|
||||
const ProfileState({
|
||||
this.status = ViewStatus.success,
|
||||
this.saving = false,
|
||||
this.failure,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final bool saving;
|
||||
final AuthFailure? failure;
|
||||
final AppUser? user;
|
||||
|
||||
ProfileState copyWith({
|
||||
ViewStatus? status,
|
||||
bool? saving,
|
||||
AuthFailure? failure,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
}) {
|
||||
return ProfileState(
|
||||
status: status ?? this.status,
|
||||
saving: saving ?? this.saving,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// إكمال الملف بعد أول دخول. عقد الخادم يقبل `{ name, language }` فقط
|
||||
/// (docs/38 §3) — لا اسم أول/أخير ولا بريد كما في سيرو (docs/39 §2).
|
||||
class ProfileCubit extends Cubit<ProfileState> {
|
||||
ProfileCubit(this._repo) : super(const ProfileState());
|
||||
|
||||
final AuthRepository _repo;
|
||||
|
||||
Future<AppUser?> save(String name) async {
|
||||
emit(state.copyWith(saving: true, clearFailure: true));
|
||||
try {
|
||||
final user = await _repo.updateProfile(name: name.trim());
|
||||
emit(state.copyWith(saving: false, user: user));
|
||||
return user;
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/storage/token_store.dart';
|
||||
import '../../../core/session/app_user.dart';
|
||||
import '../../../core/session/session_source.dart';
|
||||
|
||||
/// كل نداءات المصادقة (docs/38 §2). الـCubit لا ينادي Dio مباشرة أبداً
|
||||
/// (docs/23 §3).
|
||||
class AuthRepository implements SessionSource {
|
||||
AuthRepository(this._api, this._tokens);
|
||||
|
||||
final ApiClient _api;
|
||||
final TokenStore _tokens;
|
||||
|
||||
/// حدّ الخادم: ثلاثة طلبات كل خمس دقائق — الواجهة تمنع الضغط المتكرر
|
||||
/// بعدّاد تنازلي (docs/39 §2).
|
||||
Future<void> sendOtp(String phone) async {
|
||||
await _api.post<Map<String, dynamic>>(
|
||||
'/auth/send-otp',
|
||||
body: {'phone': phone},
|
||||
);
|
||||
}
|
||||
|
||||
/// عند النجاح تُحفظ الرموز **قبل** أن يعود المستخدم — أي نداء تالٍ يجدها.
|
||||
Future<AppUser> verifyOtp({
|
||||
required String phone,
|
||||
required String code,
|
||||
String? referralCode,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/auth/verify-otp',
|
||||
body: {
|
||||
'phone': phone,
|
||||
'code': code,
|
||||
if (referralCode != null && referralCode.isNotEmpty)
|
||||
'referral_code': referralCode,
|
||||
},
|
||||
);
|
||||
|
||||
await _tokens.save(
|
||||
accessToken: res['access_token'] as String,
|
||||
refreshToken: res['refresh_token'] as String,
|
||||
// مفتاح توقيع الطلبات المالية (docs/38 §8) — يُخزَّن الآن ويُستهلك لاحقاً.
|
||||
signingKey: res['signing_key'] as String?,
|
||||
);
|
||||
|
||||
return AppUser.fromJson(res['user'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppUser> me() async {
|
||||
final res = await _api.get<Map<String, dynamic>>('/users/me');
|
||||
return AppUser.fromJson(res);
|
||||
}
|
||||
|
||||
Future<AppUser> updateProfile({String? name, String? language}) async {
|
||||
final res = await _api.patch<Map<String, dynamic>>(
|
||||
'/users/me',
|
||||
body: {
|
||||
'name': ?name,
|
||||
'language': ?language,
|
||||
},
|
||||
);
|
||||
return AppUser.fromJson(res);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() => _tokens.clear();
|
||||
|
||||
@override
|
||||
bool get hasSession => _tokens.isLoggedIn;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user