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/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/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 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config.dart';
|
||||||
import '../storage/token_store.dart';
|
import '../storage/token_store.dart';
|
||||||
import 'api_exception.dart';
|
import 'api_exception.dart';
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
+4
-3
@@ -2,7 +2,8 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../build_config.dart';
|
||||||
|
import '../config.dart';
|
||||||
import '../storage/token_store.dart';
|
import '../storage/token_store.dart';
|
||||||
|
|
||||||
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
|
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
|
||||||
@@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
RequestInterceptorHandler handler,
|
RequestInterceptorHandler handler,
|
||||||
) async {
|
) async {
|
||||||
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
|
options.headers['x-tenant-id'] = BuildConfig.tenantSlug;
|
||||||
options.headers['x-app-role'] = AppConfig.appRole;
|
options.headers['x-app-role'] = AppConfig.appRole;
|
||||||
|
|
||||||
final deviceId = await _tokens.deviceId();
|
final deviceId = await _tokens.deviceId();
|
||||||
@@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
'/auth/refresh',
|
'/auth/refresh',
|
||||||
data: {'refresh_token': refresh},
|
data: {'refresh_token': refresh},
|
||||||
options: Options(headers: {
|
options: Options(headers: {
|
||||||
'x-tenant-id': AppConfig.tenantSlug,
|
'x-tenant-id': BuildConfig.tenantSlug,
|
||||||
'x-app-role': AppConfig.appRole,
|
'x-app-role': AppConfig.appRole,
|
||||||
'x-device-id': ?deviceId,
|
'x-device-id': ?deviceId,
|
||||||
}),
|
}),
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// ⚠️ ملف مُولَّد (docs/23 §1 — N3). لا يُحرَّر يدوياً؛ يُعاد توليده لكل
|
||||||
|
// مستأجر/طبقة عند البناء. القيم هنا هي القيم الافتراضية للمستأجر التجريبي.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// المتغيّر الوحيد بين المستأجرين: **لون · لوغو · اسم** (docs/26 §0).
|
||||||
|
/// أي شيء آخر يتغيّر بين مستأجرين = خطأ يُصحَّح.
|
||||||
|
class BuildConfig {
|
||||||
|
const BuildConfig._();
|
||||||
|
|
||||||
|
static const String appName = 'Tripz Driver';
|
||||||
|
static const String tenantSlug = 'siro';
|
||||||
|
|
||||||
|
/// اللون البذرة — منه تُشتقّ `ColorScheme` للوضعين.
|
||||||
|
static const Color seedColor = Color(0xFF1F6FEB);
|
||||||
|
|
||||||
|
static const String logoAsset = 'assets/images/logo.png';
|
||||||
|
static const String splashLogoAsset = 'assets/images/splash_logo.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
|
||||||
|
/// من الـbinary عند إطفائها — خريطة في وقت التشغيل تُبقي الكود قابلاً
|
||||||
|
/// للاستخراج.
|
||||||
|
///
|
||||||
|
/// هذه طبقة **الطبقة المُباعة** (lite/pro/max). طبقة ثانية مستقلة تأتي من
|
||||||
|
/// `GET /tenant/config/:slug` وقت التشغيل (docs/38 §10) وتخفي ما اشتراه
|
||||||
|
/// المستأجر فعلاً — الاثنتان تتقاطعان: العَلَم `const` يقرّر ما يُبنى، وردّ
|
||||||
|
/// الخادم يقرّر ما يُعرض ممّا بُني.
|
||||||
|
class Features {
|
||||||
|
const Features._();
|
||||||
|
|
||||||
|
static const bool wallet = true;
|
||||||
|
static const bool chat = true;
|
||||||
|
static const bool calls = true;
|
||||||
|
static const bool rideTypes = true;
|
||||||
|
static const bool coupons = true;
|
||||||
|
static const bool geofence = false;
|
||||||
|
static const bool transit = false;
|
||||||
|
static const bool marketIntel = false;
|
||||||
|
}
|
||||||
+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 {
|
class AppConfig {
|
||||||
const AppConfig._();
|
const AppConfig._();
|
||||||
|
|
||||||
/// أصل الـAPI **مع** `/api` — كما في docs/38.
|
/// أصل الـAPI **مع** `/api` (docs/38).
|
||||||
static const String apiBaseUrl = String.fromEnvironment(
|
static const String apiBaseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
||||||
@@ -17,18 +17,19 @@ class AppConfig {
|
|||||||
defaultValue: 'https://tripz-api.intaleqapp.com',
|
defaultValue: 'https://tripz-api.intaleqapp.com',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// **slug** المستأجر لا الـUUID — مصيدة docs/38 §12.9.
|
|
||||||
static const String tenantSlug = String.fromEnvironment(
|
|
||||||
'TENANT_SLUG',
|
|
||||||
defaultValue: 'siro',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان
|
/// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان
|
||||||
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
||||||
static const String appRole = 'driver';
|
static const String appRole = 'driver';
|
||||||
|
|
||||||
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار
|
/// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
|
||||||
/// حتى 401 يعني فشل طلب في منتصف رحلة.
|
static const int otpLength = 4;
|
||||||
|
|
||||||
|
/// مهلة إعادة إرسال الرمز. الخادم يحدّ `send-otp` بثلاثة طلبات كل خمس
|
||||||
|
/// دقائق، فبلا عدّاد تنازلي يُحظر المستخدم بثلاث ضغطات (docs/39 §2).
|
||||||
|
static const Duration otpResendCooldown = Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)،
|
||||||
|
/// فالانتظار حتى 401 يعني فشل طلب في منتصف رحلة.
|
||||||
static const Duration tokenRefreshLeeway = Duration(seconds: 60);
|
static const Duration tokenRefreshLeeway = Duration(seconds: 60);
|
||||||
|
|
||||||
static const Duration connectTimeout = Duration(seconds: 20);
|
static const Duration connectTimeout = Duration(seconds: 20);
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../build_config.dart';
|
||||||
|
import 'tokens.dart';
|
||||||
|
import 'tripz_colors.dart';
|
||||||
|
import 'typography.dart';
|
||||||
|
|
||||||
|
/// دالة بناء الثيم الوحيدة (docs/26 §4): مدخلان لا ثالث لهما.
|
||||||
|
///
|
||||||
|
/// الاتجاه (RTL/LTR) **لا يُفرض هنا** — `MaterialApp` يشتقّه من الـlocale
|
||||||
|
/// وحده. فرض `Directionality.rtl` يكسر الإنجليزية (docs/26 §4).
|
||||||
|
ThemeData buildTheme(Brightness brightness, Locale locale) {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: BuildConfig.seedColor,
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
final isDark = brightness == Brightness.dark;
|
||||||
|
final texts = buildTextTheme(brightness, locale);
|
||||||
|
final tripz = isDark ? TripzColors.dark : TripzColors.light;
|
||||||
|
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
brightness: brightness,
|
||||||
|
fontFamily: AppFonts.familyFor(locale),
|
||||||
|
textTheme: texts,
|
||||||
|
scaffoldBackgroundColor: scheme.surface,
|
||||||
|
extensions: [tripz],
|
||||||
|
|
||||||
|
// AppBar مسطّح بلا ظل (docs/26 §6).
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
foregroundColor: scheme.onSurface,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
titleTextStyle: texts.titleLarge?.copyWith(color: scheme.onSurface),
|
||||||
|
),
|
||||||
|
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: tripz.surfaceRaised,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: Radii.card_,
|
||||||
|
side: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
bottomSheetTheme: BottomSheetThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
showDragHandle: true,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: Radii.sheetTop),
|
||||||
|
),
|
||||||
|
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: isDark ? scheme.surfaceContainerHigh : scheme.surface,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Space.md,
|
||||||
|
vertical: Space.sm,
|
||||||
|
),
|
||||||
|
border: _fieldBorder(scheme.outlineVariant),
|
||||||
|
enabledBorder: _fieldBorder(scheme.outlineVariant),
|
||||||
|
focusedBorder: _fieldBorder(scheme.primary, width: 1.5),
|
||||||
|
errorBorder: _fieldBorder(scheme.error),
|
||||||
|
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
|
||||||
|
errorStyle: texts.bodySmall?.copyWith(color: scheme.error),
|
||||||
|
),
|
||||||
|
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: scheme.outlineVariant,
|
||||||
|
space: 1,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
|
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
backgroundColor: scheme.inverseSurface,
|
||||||
|
contentTextStyle:
|
||||||
|
texts.bodyMedium?.copyWith(color: scheme.onInverseSurface),
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: Radii.card_),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) {
|
||||||
|
return OutlineInputBorder(
|
||||||
|
borderRadius: Radii.field_,
|
||||||
|
borderSide: BorderSide(color: color, width: width),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// الرموز الثابتة (docs/26 §3). **الشاشة لا تخترع رقماً** — لا مسافة ولا زاوية
|
||||||
|
/// ولا مدّة خارج هذا الملف.
|
||||||
|
class Space {
|
||||||
|
const Space._();
|
||||||
|
|
||||||
|
/// سلّم مضاعفات الأربعة.
|
||||||
|
static const double xxs = 4;
|
||||||
|
static const double xs = 8;
|
||||||
|
static const double sm = 12;
|
||||||
|
|
||||||
|
/// حشوة الصفحة الافتراضية.
|
||||||
|
static const double md = 16;
|
||||||
|
static const double lg = 24;
|
||||||
|
static const double xl = 32;
|
||||||
|
static const double xxl = 48;
|
||||||
|
|
||||||
|
static const EdgeInsets page = EdgeInsets.all(md);
|
||||||
|
static const EdgeInsets pageH = EdgeInsets.symmetric(horizontal: md);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Radii {
|
||||||
|
const Radii._();
|
||||||
|
|
||||||
|
/// حقول وشارات.
|
||||||
|
static const double field = 8;
|
||||||
|
|
||||||
|
/// أزرار وبطاقات.
|
||||||
|
static const double card = 12;
|
||||||
|
|
||||||
|
/// الأوراق السفلية.
|
||||||
|
static const double sheet = 24;
|
||||||
|
|
||||||
|
static const BorderRadius field_ = BorderRadius.all(Radius.circular(field));
|
||||||
|
static const BorderRadius card_ = BorderRadius.all(Radius.circular(card));
|
||||||
|
static const BorderRadius sheetTop = BorderRadius.vertical(
|
||||||
|
top: Radius.circular(sheet),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Motion {
|
||||||
|
const Motion._();
|
||||||
|
|
||||||
|
/// استجابة لمسة.
|
||||||
|
static const Duration tap = Duration(milliseconds: 120);
|
||||||
|
|
||||||
|
/// انتقال ضمن الشاشة.
|
||||||
|
static const Duration inScreen = Duration(milliseconds: 240);
|
||||||
|
|
||||||
|
/// ورقة سفلية أو صفحة.
|
||||||
|
static const Duration page = Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
static const Curve curve = Curves.easeOutCubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Sizes {
|
||||||
|
const Sizes._();
|
||||||
|
|
||||||
|
static const double iconSm = 20;
|
||||||
|
static const double icon = 24;
|
||||||
|
static const double iconLg = 32;
|
||||||
|
|
||||||
|
/// ارتفاع زر الـCTA وحقل الإدخال.
|
||||||
|
static const double control = 52;
|
||||||
|
|
||||||
|
/// أدنى هدف لمس مقبول.
|
||||||
|
static const double touchTarget = 48;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// الأدوار الدلالية التي لا يوفّرها `ColorScheme` (docs/26 §2).
|
||||||
|
///
|
||||||
|
/// الشاشة تقول `context.tripzColors.success` — **ولا تقرّر درجة اللون بنفسها،
|
||||||
|
/// ولا تكتب hex**. تُشتقّ كلها من السطوع لا من لون المستأجر، فتبقى ثابتة عبر
|
||||||
|
/// كل المستأجرين كما يفرض عقد §0.
|
||||||
|
@immutable
|
||||||
|
class TripzColors extends ThemeExtension<TripzColors> {
|
||||||
|
const TripzColors({
|
||||||
|
required this.success,
|
||||||
|
required this.onSuccess,
|
||||||
|
required this.warning,
|
||||||
|
required this.onWarning,
|
||||||
|
required this.danger,
|
||||||
|
required this.onDanger,
|
||||||
|
required this.info,
|
||||||
|
required this.surfaceRaised,
|
||||||
|
required this.mapRoute,
|
||||||
|
required this.mapOrigin,
|
||||||
|
required this.mapDestination,
|
||||||
|
required this.skeleton,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color success;
|
||||||
|
final Color onSuccess;
|
||||||
|
final Color warning;
|
||||||
|
final Color onWarning;
|
||||||
|
final Color danger;
|
||||||
|
final Color onDanger;
|
||||||
|
final Color info;
|
||||||
|
|
||||||
|
/// السطح المرتفع — في الليلي ارتفاعٌ **بالسطوع لا بالظل** (docs/26 §2).
|
||||||
|
final Color surfaceRaised;
|
||||||
|
|
||||||
|
final Color mapRoute;
|
||||||
|
final Color mapOrigin;
|
||||||
|
final Color mapDestination;
|
||||||
|
|
||||||
|
/// لون هيكل التحميل (skeleton) — لا spinner فارغ (docs/26 §8.4).
|
||||||
|
final Color skeleton;
|
||||||
|
|
||||||
|
static const light = TripzColors(
|
||||||
|
success: Color(0xFF1B8A5A),
|
||||||
|
onSuccess: Color(0xFFFFFFFF),
|
||||||
|
warning: Color(0xFFB4690E),
|
||||||
|
onWarning: Color(0xFFFFFFFF),
|
||||||
|
danger: Color(0xFFC5303B),
|
||||||
|
onDanger: Color(0xFFFFFFFF),
|
||||||
|
info: Color(0xFF2563A8),
|
||||||
|
surfaceRaised: Color(0xFFFFFFFF),
|
||||||
|
mapRoute: Color(0xFF1F6FEB),
|
||||||
|
mapOrigin: Color(0xFF1B8A5A),
|
||||||
|
mapDestination: Color(0xFFC5303B),
|
||||||
|
skeleton: Color(0xFFE6E8EC),
|
||||||
|
);
|
||||||
|
|
||||||
|
static const dark = TripzColors(
|
||||||
|
success: Color(0xFF4ECB8D),
|
||||||
|
onSuccess: Color(0xFF00281A),
|
||||||
|
warning: Color(0xFFE9A63B),
|
||||||
|
onWarning: Color(0xFF2A1A00),
|
||||||
|
danger: Color(0xFFF2707A),
|
||||||
|
onDanger: Color(0xFF33000A),
|
||||||
|
info: Color(0xFF7FB2F0),
|
||||||
|
// ليس أسود صرفاً — رمادي داكن متدرّج.
|
||||||
|
surfaceRaised: Color(0xFF20232A),
|
||||||
|
mapRoute: Color(0xFF5B9BFF),
|
||||||
|
mapOrigin: Color(0xFF4ECB8D),
|
||||||
|
mapDestination: Color(0xFFF2707A),
|
||||||
|
skeleton: Color(0xFF2C3038),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TripzColors copyWith({
|
||||||
|
Color? success,
|
||||||
|
Color? onSuccess,
|
||||||
|
Color? warning,
|
||||||
|
Color? onWarning,
|
||||||
|
Color? danger,
|
||||||
|
Color? onDanger,
|
||||||
|
Color? info,
|
||||||
|
Color? surfaceRaised,
|
||||||
|
Color? mapRoute,
|
||||||
|
Color? mapOrigin,
|
||||||
|
Color? mapDestination,
|
||||||
|
Color? skeleton,
|
||||||
|
}) {
|
||||||
|
return TripzColors(
|
||||||
|
success: success ?? this.success,
|
||||||
|
onSuccess: onSuccess ?? this.onSuccess,
|
||||||
|
warning: warning ?? this.warning,
|
||||||
|
onWarning: onWarning ?? this.onWarning,
|
||||||
|
danger: danger ?? this.danger,
|
||||||
|
onDanger: onDanger ?? this.onDanger,
|
||||||
|
info: info ?? this.info,
|
||||||
|
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
|
||||||
|
mapRoute: mapRoute ?? this.mapRoute,
|
||||||
|
mapOrigin: mapOrigin ?? this.mapOrigin,
|
||||||
|
mapDestination: mapDestination ?? this.mapDestination,
|
||||||
|
skeleton: skeleton ?? this.skeleton,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
TripzColors lerp(ThemeExtension<TripzColors>? other, double t) {
|
||||||
|
if (other is! TripzColors) return this;
|
||||||
|
return TripzColors(
|
||||||
|
success: Color.lerp(success, other.success, t)!,
|
||||||
|
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
|
||||||
|
warning: Color.lerp(warning, other.warning, t)!,
|
||||||
|
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
|
||||||
|
danger: Color.lerp(danger, other.danger, t)!,
|
||||||
|
onDanger: Color.lerp(onDanger, other.onDanger, t)!,
|
||||||
|
info: Color.lerp(info, other.info, t)!,
|
||||||
|
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
|
||||||
|
mapRoute: Color.lerp(mapRoute, other.mapRoute, t)!,
|
||||||
|
mapOrigin: Color.lerp(mapOrigin, other.mapOrigin, t)!,
|
||||||
|
mapDestination: Color.lerp(mapDestination, other.mapDestination, t)!,
|
||||||
|
skeleton: Color.lerp(skeleton, other.skeleton, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TripzColorsX on BuildContext {
|
||||||
|
TripzColors get tripzColors => Theme.of(this).extension<TripzColors>()!;
|
||||||
|
ColorScheme get colors => Theme.of(this).colorScheme;
|
||||||
|
TextTheme get texts => Theme.of(this).textTheme;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// الخطوط (docs/26 §1) — ملفات محلية في `assets/fonts/`. حزمة `google_fonts`
|
||||||
|
/// ممنوعة: الشكل يجب أن يكون حتمياً من أول إقلاع بلا شبكة.
|
||||||
|
class AppFonts {
|
||||||
|
const AppFonts._();
|
||||||
|
|
||||||
|
static const arabic = 'IBMPlexSansArabic';
|
||||||
|
static const latin = 'Inter';
|
||||||
|
|
||||||
|
/// الخط يتبع اللغة، والآخر احتياط — كي لا تنكسر كلمة عربية داخل جملة
|
||||||
|
/// إنجليزية ولا العكس.
|
||||||
|
static String familyFor(Locale locale) =>
|
||||||
|
locale.languageCode == 'ar' ? arabic : latin;
|
||||||
|
|
||||||
|
static List<String> fallbackFor(Locale locale) =>
|
||||||
|
locale.languageCode == 'ar' ? const [latin] : const [arabic];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// أرقام tabular: تمنع «رقص» الأعمدة حين تتغيّر القيمة — للأسعار والعدّادات
|
||||||
|
/// والمسافات الحيّة (docs/26 §1). يُغني عن خط `digit` المتقاعد.
|
||||||
|
const tabularFigures = [FontFeature.tabularFigures()];
|
||||||
|
|
||||||
|
TextTheme buildTextTheme(Brightness brightness, Locale locale) {
|
||||||
|
final base = brightness == Brightness.dark
|
||||||
|
? Typography.material2021().white
|
||||||
|
: Typography.material2021().black;
|
||||||
|
|
||||||
|
final family = AppFonts.familyFor(locale);
|
||||||
|
final fallback = AppFonts.fallbackFor(locale);
|
||||||
|
|
||||||
|
return base
|
||||||
|
.apply(fontFamily: family, fontFamilyFallback: fallback)
|
||||||
|
.copyWith(
|
||||||
|
displaySmall: base.displaySmall?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
headlineMedium: base.headlineMedium?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
headlineSmall: base.headlineSmall?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
titleLarge: base.titleLarge?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
titleMedium: base.titleMedium?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
labelLarge: base.labelLarge?.copyWith(
|
||||||
|
fontFamily: family,
|
||||||
|
fontFamilyFallback: fallback,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// نمط الأرقام الحيّة — يُستعمل للأجرة والرصيد والعدّاد التنازلي.
|
||||||
|
/// دائماً Inter بأرقام tabular، مهما كانت لغة الواجهة (docs/26 §5: الأرقام
|
||||||
|
/// لاتينية في اللغتين).
|
||||||
|
TextStyle numericStyle(TextStyle? base) => (base ?? const TextStyle()).copyWith(
|
||||||
|
fontFamily: AppFonts.latin,
|
||||||
|
fontFeatures: tabularFigures,
|
||||||
|
);
|
||||||
@@ -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/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import 'app/app.dart';
|
import 'app.dart';
|
||||||
import 'core/di/injector.dart';
|
import 'core/di.dart';
|
||||||
|
import 'core/session/session_cubit.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await setupInjector();
|
await setupInjector();
|
||||||
runApp(const TripzApp());
|
runApp(
|
||||||
|
// الجلسة فوق الجميع: الراوتر والشاشات يقرآنها من نقطة واحدة.
|
||||||
|
BlocProvider.value(value: sl<SessionCubit>(), child: const TripzApp()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.4.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.3"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -121,14 +137,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
ffi_leak_tracker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ffi_leak_tracker
|
|
||||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.2"
|
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -207,10 +215,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_windows
|
name: flutter_secure_storage_windows
|
||||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.2"
|
version: "4.1.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -437,6 +445,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.3"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.1"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: "11b7e94a9d2fbee23c27f0cae0105c6266c03fd83b9a2eda6cf09141fc82624b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.5.0"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.4+1"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -622,10 +678,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "5.15.0"
|
||||||
|
win32_registry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32_registry
|
||||||
|
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
|
|
||||||
|
# م3: الأذونات وبصمة الجهاز
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
device_info_plus: ^12.3.0
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
||||||
# م3: device_info_plus · crypto (توقيع الطلبات وبصمة الجهاز)
|
# م3 لاحقاً: crypto (توقيع طلبات النقاط المالية)
|
||||||
# م4: الخريطة · geolocator · socket_io_client
|
# م4: الخريطة · geolocator · socket_io_client
|
||||||
# م5: التقييم والمحفظة م6: الإشعارات والدردشة
|
# م5: التقييم والمحفظة م6: الإشعارات والدردشة
|
||||||
|
|
||||||
@@ -44,6 +48,9 @@ dev_dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
# توليد الترجمة من ARB (docs/26 §4)
|
||||||
|
generate: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/
|
- assets/
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||
@@ -43,3 +43,6 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# مُولَّد من ARB — لا يُحرَّر
|
||||||
|
lib/core/l10n/gen/
|
||||||
|
|||||||
@@ -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 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config.dart';
|
||||||
import '../storage/token_store.dart';
|
import '../storage/token_store.dart';
|
||||||
import 'api_exception.dart';
|
import 'api_exception.dart';
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
+4
-3
@@ -2,7 +2,8 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../build_config.dart';
|
||||||
|
import '../config.dart';
|
||||||
import '../storage/token_store.dart';
|
import '../storage/token_store.dart';
|
||||||
|
|
||||||
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
|
/// يثبّت ترويسات كل طلب، ويجدّد التوكن **استباقياً** قبل انتهائه.
|
||||||
@@ -35,7 +36,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
RequestInterceptorHandler handler,
|
RequestInterceptorHandler handler,
|
||||||
) async {
|
) async {
|
||||||
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
|
options.headers['x-tenant-id'] = BuildConfig.tenantSlug;
|
||||||
options.headers['x-app-role'] = AppConfig.appRole;
|
options.headers['x-app-role'] = AppConfig.appRole;
|
||||||
|
|
||||||
final deviceId = await _tokens.deviceId();
|
final deviceId = await _tokens.deviceId();
|
||||||
@@ -100,7 +101,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
'/auth/refresh',
|
'/auth/refresh',
|
||||||
data: {'refresh_token': refresh},
|
data: {'refresh_token': refresh},
|
||||||
options: Options(headers: {
|
options: Options(headers: {
|
||||||
'x-tenant-id': AppConfig.tenantSlug,
|
'x-tenant-id': BuildConfig.tenantSlug,
|
||||||
'x-app-role': AppConfig.appRole,
|
'x-app-role': AppConfig.appRole,
|
||||||
'x-device-id': ?deviceId,
|
'x-device-id': ?deviceId,
|
||||||
}),
|
}),
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// ⚠️ ملف مُولَّد (docs/23 §1 — N3). لا يُحرَّر يدوياً؛ يُعاد توليده لكل
|
||||||
|
// مستأجر/طبقة عند البناء. القيم هنا هي القيم الافتراضية للمستأجر التجريبي.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// المتغيّر الوحيد بين المستأجرين: **لون · لوغو · اسم** (docs/26 §0).
|
||||||
|
/// أي شيء آخر يتغيّر بين مستأجرين = خطأ يُصحَّح.
|
||||||
|
class BuildConfig {
|
||||||
|
const BuildConfig._();
|
||||||
|
|
||||||
|
static const String appName = 'Tripz';
|
||||||
|
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 {
|
class AppConfig {
|
||||||
const AppConfig._();
|
const AppConfig._();
|
||||||
|
|
||||||
/// أصل الـAPI **مع** `/api` — كما في docs/38.
|
/// أصل الـAPI **مع** `/api` (docs/38).
|
||||||
static const String apiBaseUrl = String.fromEnvironment(
|
static const String apiBaseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
defaultValue: 'https://tripz-api.intaleqapp.com/api',
|
||||||
@@ -17,18 +17,19 @@ class AppConfig {
|
|||||||
defaultValue: 'https://tripz-api.intaleqapp.com',
|
defaultValue: 'https://tripz-api.intaleqapp.com',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// **slug** المستأجر لا الـUUID — مصيدة docs/38 §12.9.
|
|
||||||
static const String tenantSlug = String.fromEnvironment(
|
|
||||||
'TENANT_SLUG',
|
|
||||||
defaultValue: 'siro',
|
|
||||||
);
|
|
||||||
|
|
||||||
/// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان
|
/// يحدّد هوية المستخدم على الخادم: نفس الرقم بدورين = حسابان منفصلان
|
||||||
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
||||||
static const String appRole = 'rider';
|
static const String appRole = 'rider';
|
||||||
|
|
||||||
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)، فالانتظار
|
/// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
|
||||||
/// حتى 401 يعني فشل طلب في منتصف رحلة.
|
static const int otpLength = 4;
|
||||||
|
|
||||||
|
/// مهلة إعادة إرسال الرمز. الخادم يحدّ `send-otp` بثلاثة طلبات كل خمس
|
||||||
|
/// دقائق، فبلا عدّاد تنازلي يُحظر المستخدم بثلاث ضغطات (docs/39 §2).
|
||||||
|
static const Duration otpResendCooldown = Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// هامش التجديد الاستباقي. عمر التوكن 15 دقيقة فقط (docs/38 §2)،
|
||||||
|
/// فالانتظار حتى 401 يعني فشل طلب في منتصف رحلة.
|
||||||
static const Duration tokenRefreshLeeway = Duration(seconds: 60);
|
static const Duration tokenRefreshLeeway = Duration(seconds: 60);
|
||||||
|
|
||||||
static const Duration connectTimeout = Duration(seconds: 20);
|
static const Duration connectTimeout = Duration(seconds: 20);
|
||||||
@@ -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