feat(apps): تسجيل السائق والدردشة والكوبون — إكمال التغطية
فحص آليّ لنقاط docs/38 مقابل الكود كشف ثغرات لم تظهر بالقراءة. ## تسجيل السائق — أخطر ثغرة لم يكن موجوداً أصلاً: سائق جديد يسجّل دخوله ثم يقف. بُنيت features/onboarding كاملة — تقديم ← ملف ← مركبة ← وثائق ← انتظار الاعتماد. - الخطوة تُشتقّ من حالة الخادم لا من تقدّم محلّي: سائق يعيد تثبيت التطبيق يعود إلى حيث وقف لا إلى البداية - الاعتماد يقع على الخادم تلقائياً حين تكتمل الوثائق؛ التطبيق لا يعتمد أحداً - عند الاعتماد خروج إجباري: الدور يتغيّر على الخادم والتوكن القديم يحمل القديم، فتفشل نقاط السائق بـ403 (مصيدة docs/38 §5) - بوابة توجيه: مستخدم دوره ليس driver يُحجز في /onboarding - الوثائق تُصوَّر بالكاميرا لا من المعرض: أصعب تزويراً ## الدردشة والكوبون - Features.chat كان مفعّلاً بلا ميزة. features/chat باستطلاع كل خمس ثوان — قائمة أحداث الخادم لا تتضمّن الرسائل (docs/38 §9)، فالاستطلاع قيد خادم لا اختيار تصميمي - الكوبون: حقل في ورقة التأكيد خلف طبقتَي الميزات، يُقيَّم على الخادم ## Features.calls أُطفئ صراحةً الخادم يدعم WebRTC والتطبيق لا. عَلَم مفعّل بلا ميزة كذبٌ على القارئ التالي. ## بنية - ApiClient.upload للرفع متعدّد الأجزاء - AuthFailure → ApiFailure في core/api: يخدم المصادقة والملف والتسجيل - tool/sync_from_rider.sh: المزامنة اليدوية بين التوأمين انكسرت أربع مرات، فصارت سكربتاً واحداً يعرّف ما يملكه كل تطبيق flutter analyze نظيف · الراكب 101 ملف/7,809 سطر · السائق 110 ملف/8,116 سطر. فحوص آلية: صفر استيراد بين ميزتين · صفر عَلَم مفعّل بلا ميزة · كل نقاط العقد المخصّصة للتطبيقين مستهلكة. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
096acae74e
commit
bb2c0de478
@@ -47,6 +47,22 @@ class ApiClient {
|
||||
|
||||
Future<T> delete<T>(String path) => _send(() => _dio.delete<T>(path));
|
||||
|
||||
/// رفع ملف — الحقل اسمه `file` كما يتوقّعه `FileInterceptor` على الخادم.
|
||||
/// يمرّ بنفس الـinterceptor فيحمل التوكن والمستأجر والجهاز.
|
||||
Future<T> upload<T>(
|
||||
String path, {
|
||||
required String filePath,
|
||||
Map<String, dynamic> fields = const {},
|
||||
}) {
|
||||
return _send(() async {
|
||||
final form = FormData.fromMap({
|
||||
...fields,
|
||||
'file': await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
return _dio.post<T>(path, data: form);
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _send<T>(Future<Response<T>> Function() call) async {
|
||||
try {
|
||||
final res = await call();
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'api_exception.dart';
|
||||
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
|
||||
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
|
||||
/// الشاشة بلا منطق، والنص بلغتين.
|
||||
enum AuthFailure {
|
||||
enum ApiFailure {
|
||||
/// رمز خاطئ أو منتهي الصلاحية.
|
||||
invalidCode,
|
||||
|
||||
@@ -22,17 +22,17 @@ enum AuthFailure {
|
||||
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;
|
||||
extension ApiFailureX on ApiException {
|
||||
ApiFailure toApiFailure() {
|
||||
if (isRateLimited) return ApiFailure.rateLimited;
|
||||
if (kind == ApiErrorKind.network) return ApiFailure.network;
|
||||
if (kind == ApiErrorKind.timeout) return ApiFailure.timeout;
|
||||
if (isUnauthorized) {
|
||||
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
|
||||
return message.contains('Too many')
|
||||
? AuthFailure.tooManyAttempts
|
||||
: AuthFailure.invalidCode;
|
||||
? ApiFailure.tooManyAttempts
|
||||
: ApiFailure.invalidCode;
|
||||
}
|
||||
return AuthFailure.server;
|
||||
return ApiFailure.server;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class Features {
|
||||
|
||||
static const bool wallet = true;
|
||||
static const bool chat = true;
|
||||
static const bool calls = true;
|
||||
/// WebRTC غير مبنيّ بعد — الخادم يدعمه (`call:*` في docs/38 §9) لكن
|
||||
/// التطبيق لا. يبقى مطفأً حتى يُبنى ويُختبر على جهاز حقيقي.
|
||||
static const bool calls = false;
|
||||
static const bool rideTypes = true;
|
||||
static const bool coupons = true;
|
||||
static const bool geofence = false;
|
||||
|
||||
@@ -21,6 +21,10 @@ import '../features/settings/cubit/settings_cubit.dart';
|
||||
|
||||
import '../features/trip/cubit/history_cubit.dart';
|
||||
|
||||
import '../features/chat/cubit/chat_cubit.dart';
|
||||
import '../features/chat/data/chat_repository.dart';
|
||||
import '../features/onboarding/cubit/onboarding_cubit.dart';
|
||||
import '../features/onboarding/data/driver_repository.dart';
|
||||
import '../features/trip/cubit/duty_cubit.dart';
|
||||
|
||||
import '../features/trip/data/history_repository.dart';
|
||||
@@ -99,12 +103,21 @@ Future<void> setupInjector() async {
|
||||
sl.registerSingleton<TenantCubit>(TenantCubit(sl(), prefs));
|
||||
sl.registerSingleton<PushService>(PushService(sl()));
|
||||
|
||||
sl.registerSingleton<DriverRepository>(DriverRepository(sl()));
|
||||
sl.registerFactory<OnboardingCubit>(() => OnboardingCubit(sl()));
|
||||
|
||||
sl.registerSingleton<HistoryRepository>(HistoryRepository(sl()));
|
||||
sl.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
|
||||
|
||||
sl.registerSingleton<RatingRepository>(RatingRepository(sl()));
|
||||
sl.registerFactory<RatingCubit>(() => RatingCubit(sl()));
|
||||
|
||||
// الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5).
|
||||
if (Features.chat) {
|
||||
sl.registerSingleton<ChatRepository>(ChatRepository(sl()));
|
||||
sl.registerFactory<ChatCubit>(() => ChatCubit(sl()));
|
||||
}
|
||||
|
||||
// المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5).
|
||||
if (Features.wallet) {
|
||||
sl.registerSingleton<WalletRepository>(WalletRepository(sl()));
|
||||
|
||||
@@ -188,5 +188,41 @@
|
||||
"aboutVersion": "الإصدار",
|
||||
"notifTitle": "الإشعارات",
|
||||
"notifEnable": "تفعيل الإشعارات",
|
||||
"notifDenied": "الإشعارات موقوفة من إعدادات النظام"
|
||||
"notifDenied": "الإشعارات موقوفة من إعدادات النظام",
|
||||
"chatTitle": "المحادثة",
|
||||
"chatHint": "اكتب رسالة",
|
||||
"chatEmpty": "لا رسائل بعد",
|
||||
"rideCoupon": "كود خصم",
|
||||
"rideCouponApply": "تطبيق",
|
||||
"obApplyTitle": "سجّل كسائق",
|
||||
"obApplyLead": "خطوات قصيرة ثم تبدأ العمل",
|
||||
"obVehicleMake": "شركة السيارة",
|
||||
"obServiceClass": "نوع الخدمة",
|
||||
"obApplySubmit": "ابدأ التسجيل",
|
||||
"obProfileTitle": "بياناتك",
|
||||
"obNameArabic": "الاسم كما في الهوية",
|
||||
"obNationalNumber": "الرقم الوطني",
|
||||
"obGender": "الجنس",
|
||||
"obGenderMale": "ذكر",
|
||||
"obGenderFemale": "أنثى",
|
||||
"obVehicleTitle": "مركبتك",
|
||||
"obVehicleModel": "الطراز",
|
||||
"obVehicleYear": "سنة الصنع",
|
||||
"obVehicleColor": "اللون",
|
||||
"obVehiclePlate": "رقم اللوحة",
|
||||
"obDocsTitle": "وثائقك",
|
||||
"obDocsLead": "صوّر كل وثيقة بوضوح — الاعتماد يتم بعد مراجعتها",
|
||||
"obDocMissing": "مطلوبة",
|
||||
"obDocPending": "قيد المراجعة",
|
||||
"obDocApproved": "مقبولة",
|
||||
"obDocRejected": "مرفوضة — أعد الرفع",
|
||||
"obDocSideFront": "الوجه الأمامي",
|
||||
"obDocSideBack": "الوجه الخلفي",
|
||||
"obDocUpload": "رفع",
|
||||
"obPendingTitle": "طلبك قيد المراجعة",
|
||||
"obPendingLead": "سنُعلمك فور اعتماد حسابك. عادةً خلال يوم عمل.",
|
||||
"obPendingRefresh": "تحديث الحالة",
|
||||
"obApprovedTitle": "تم اعتمادك",
|
||||
"obApprovedLead": "سجّل الدخول من جديد لتفعيل صلاحيات السائق",
|
||||
"obRelogin": "إعادة تسجيل الدخول"
|
||||
}
|
||||
@@ -188,5 +188,41 @@
|
||||
"aboutVersion": "Version",
|
||||
"notifTitle": "Notifications",
|
||||
"notifEnable": "Enable notifications",
|
||||
"notifDenied": "Notifications are disabled in system settings"
|
||||
"notifDenied": "Notifications are disabled in system settings",
|
||||
"chatTitle": "Chat",
|
||||
"chatHint": "Type a message",
|
||||
"chatEmpty": "No messages yet",
|
||||
"rideCoupon": "Promo code",
|
||||
"rideCouponApply": "Apply",
|
||||
"obApplyTitle": "Register as a driver",
|
||||
"obApplyLead": "A few short steps, then you're working",
|
||||
"obVehicleMake": "Car make",
|
||||
"obServiceClass": "Service class",
|
||||
"obApplySubmit": "Start registration",
|
||||
"obProfileTitle": "Your details",
|
||||
"obNameArabic": "Name as on your ID",
|
||||
"obNationalNumber": "National ID number",
|
||||
"obGender": "Gender",
|
||||
"obGenderMale": "Male",
|
||||
"obGenderFemale": "Female",
|
||||
"obVehicleTitle": "Your vehicle",
|
||||
"obVehicleModel": "Model",
|
||||
"obVehicleYear": "Year",
|
||||
"obVehicleColor": "Colour",
|
||||
"obVehiclePlate": "Plate number",
|
||||
"obDocsTitle": "Your documents",
|
||||
"obDocsLead": "Photograph each document clearly — approval follows review",
|
||||
"obDocMissing": "Required",
|
||||
"obDocPending": "Under review",
|
||||
"obDocApproved": "Approved",
|
||||
"obDocRejected": "Rejected — upload again",
|
||||
"obDocSideFront": "Front",
|
||||
"obDocSideBack": "Back",
|
||||
"obDocUpload": "Upload",
|
||||
"obPendingTitle": "Your application is under review",
|
||||
"obPendingLead": "We'll notify you as soon as it's approved. Usually within a business day.",
|
||||
"obPendingRefresh": "Refresh status",
|
||||
"obApprovedTitle": "You're approved",
|
||||
"obApprovedLead": "Sign in again to activate your driver permissions",
|
||||
"obRelogin": "Sign in again"
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import '../features/support/view/support_page.dart';
|
||||
|
||||
import '../features/trip/view/history_page.dart';
|
||||
|
||||
import '../features/chat/view/chat_page.dart';
|
||||
import '../features/onboarding/view/onboarding_page.dart';
|
||||
import '../features/trip/view/duty_page.dart';
|
||||
|
||||
import '../features/wallet/view/earnings_page.dart';
|
||||
@@ -44,6 +46,8 @@ class Routes {
|
||||
static const profileEdit = '/account';
|
||||
static const settings = '/settings';
|
||||
static const support = '/support';
|
||||
static const chat = '/chat';
|
||||
static const onboarding = '/onboarding';
|
||||
}
|
||||
|
||||
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
||||
@@ -66,6 +70,11 @@ final appRouter = GoRouter(
|
||||
// (docs/38 §7 — يُفرض عند الفتح).
|
||||
SessionStatus.authenticated when session.pendingRating != null =>
|
||||
loc == Routes.rating ? null : Routes.rating,
|
||||
// السائق غير المعتمد يُحجز في التسجيل: دوره لا يزال `rider` على
|
||||
// الخادم، فكل نقاط السائق ترجّع 403 (docs/38 §5).
|
||||
SessionStatus.authenticated
|
||||
when session.user?.role != 'driver' && loc != Routes.onboarding =>
|
||||
Routes.onboarding,
|
||||
SessionStatus.authenticated => (loc == Routes.login ||
|
||||
loc == Routes.profile ||
|
||||
loc == Routes.splash ||
|
||||
@@ -117,6 +126,15 @@ final appRouter = GoRouter(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (context, state) =>
|
||||
ChatPage(tripId: state.uri.queryParameters['trip'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.onboarding,
|
||||
builder: (context, state) => const OnboardingPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.support,
|
||||
builder: (context, state) => const SupportPage(),
|
||||
|
||||
@@ -12,20 +12,20 @@ import '../l10n/l10n.dart';
|
||||
|
||||
/// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ.
|
||||
/// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`).
|
||||
class AuthFailureText extends StatelessWidget {
|
||||
const AuthFailureText({super.key, required this.failure});
|
||||
class ApiFailureText extends StatelessWidget {
|
||||
const ApiFailureText({super.key, required this.failure});
|
||||
|
||||
final AuthFailure failure;
|
||||
final ApiFailure failure;
|
||||
|
||||
static String messageOf(BuildContext context, AuthFailure failure) {
|
||||
static String messageOf(BuildContext context, ApiFailure 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,
|
||||
ApiFailure.invalidCode => l10n.otpErrInvalid,
|
||||
ApiFailure.tooManyAttempts => l10n.otpErrTooManyAttempts,
|
||||
ApiFailure.rateLimited => l10n.otpErrRateLimited,
|
||||
ApiFailure.network => l10n.errorNetwork,
|
||||
ApiFailure.timeout => l10n.errorTimeout,
|
||||
ApiFailure.server => l10n.errorGeneric,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
|
||||
// فوراً وعمّق الحظر.
|
||||
@@ -159,7 +159,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class LoginState extends Equatable {
|
||||
final String phone;
|
||||
|
||||
final bool agreed;
|
||||
final AuthFailure? failure;
|
||||
final ApiFailure? failure;
|
||||
final PhoneError? phoneError;
|
||||
|
||||
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
|
||||
@@ -52,7 +52,7 @@ class LoginState extends Equatable {
|
||||
LoginStatus? status,
|
||||
String? phone,
|
||||
bool? agreed,
|
||||
AuthFailure? failure,
|
||||
ApiFailure? failure,
|
||||
PhoneError? phoneError,
|
||||
int? resendIn,
|
||||
bool? permissionPermanentlyDenied,
|
||||
|
||||
@@ -67,7 +67,7 @@ class _OtpViewState extends State<OtpView> {
|
||||
onCompleted: cubit.submitOtp,
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
const SizedBox(height: Space.md),
|
||||
// العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات
|
||||
// المستخدمَ خمس دقائق (docs/38 §2).
|
||||
|
||||
@@ -57,10 +57,10 @@ class PhoneView extends StatelessWidget {
|
||||
onSubmitted: (_) => cubit.submitPhone(),
|
||||
errorText: state.phoneError == null
|
||||
? null
|
||||
: AuthFailureText.phoneMessageOf(context, state.phoneError!),
|
||||
: ApiFailureText.phoneMessageOf(context, state.phoneError!),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/view_status.dart';
|
||||
import '../data/chat_repository.dart';
|
||||
|
||||
class ChatState {
|
||||
const ChatState({
|
||||
this.status = ViewStatus.idle,
|
||||
this.messages = const [],
|
||||
this.sending = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final List<ChatMessage> messages;
|
||||
final bool sending;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
class ChatCubit extends Cubit<ChatState> {
|
||||
ChatCubit(this._repo) : super(const ChatState());
|
||||
|
||||
final ChatRepository _repo;
|
||||
Timer? _poll;
|
||||
String? _tripId;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_poll?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// استطلاع كل خمس ثوانٍ — الخادم لا يبثّ الرسائل عبر السوكت
|
||||
/// (راجع `ChatRepository`). يتوقّف مع إغلاق الشاشة.
|
||||
Future<void> open(String tripId) async {
|
||||
_tripId = tripId;
|
||||
emit(const ChatState(status: ViewStatus.loading));
|
||||
await _refresh();
|
||||
_poll = Timer.periodic(const Duration(seconds: 5), (_) => _refresh());
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final tripId = _tripId;
|
||||
if (tripId == null || isClosed) return;
|
||||
try {
|
||||
final messages = await _repo.messages(tripId);
|
||||
if (isClosed) return;
|
||||
emit(ChatState(
|
||||
status: ViewStatus.success,
|
||||
messages: messages,
|
||||
sending: state.sending,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
// فشل الاستطلاع لا يمسح ما وصل: الرسائل المعروضة تبقى.
|
||||
if (!isClosed && state.messages.isEmpty) {
|
||||
emit(ChatState(status: ViewStatus.error, error: e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(String body) async {
|
||||
final tripId = _tripId;
|
||||
if (tripId == null || body.trim().isEmpty) return;
|
||||
emit(ChatState(
|
||||
status: state.status,
|
||||
messages: state.messages,
|
||||
sending: true,
|
||||
));
|
||||
try {
|
||||
await _repo.send(tripId, body.trim());
|
||||
await _refresh();
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(ChatState(status: state.status, messages: state.messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../../../core/api/api_client.dart';
|
||||
|
||||
class ChatMessage {
|
||||
const ChatMessage({
|
||||
required this.id,
|
||||
required this.body,
|
||||
required this.senderId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String body;
|
||||
final String senderId;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
|
||||
id: json['id']?.toString() ?? '',
|
||||
body: json['body'] as String? ?? json['message'] as String? ?? '',
|
||||
senderId: json['sender_id']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
/// دردشة الرحلة (docs/38 §11).
|
||||
///
|
||||
/// ⚠️ **لا حدث سوكت للرسائل**: قائمة أحداث الخادم لا تتضمّن الدردشة
|
||||
/// (docs/38 §9)، فالتحديث **باستطلاع دوري** لا ببثّ. هذا قيد الخادم لا
|
||||
/// اختيار تصميمي — ويُلغى فور إضافة الحدث.
|
||||
class ChatRepository {
|
||||
ChatRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<ChatMessage>> messages(String tripId) async {
|
||||
final res = await _api.get<List<dynamic>>('/trips/$tripId/messages');
|
||||
return res
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ChatMessage.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> send(String tripId, String body) => _api.post<Map<String, dynamic>>(
|
||||
'/trips/$tripId/messages',
|
||||
body: {'body': body},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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/di.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
import '../../../core/ui/empty_view.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_text_field.dart';
|
||||
import '../cubit/chat_cubit.dart';
|
||||
import '../data/chat_repository.dart';
|
||||
|
||||
class ChatPage extends StatefulWidget {
|
||||
const ChatPage({super.key, required this.tripId});
|
||||
|
||||
final String tripId;
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
late final ChatCubit _cubit = sl<ChatCubit>()..open(widget.tripId);
|
||||
final _input = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_input.dispose();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final myId = sl<SessionCubit>().state.user?.id;
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocBuilder<ChatCubit, ChatState>(
|
||||
builder: (context, state) => TripzScaffold(
|
||||
title: l10n.chatTitle,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
bottomAction: TripzTextField(
|
||||
controller: _input,
|
||||
hint: l10n.chatHint,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (v) {
|
||||
_cubit.send(v);
|
||||
_input.clear();
|
||||
},
|
||||
suffix: IconButton(
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
onPressed: () {
|
||||
_cubit.send(_input.text);
|
||||
_input.clear();
|
||||
},
|
||||
),
|
||||
),
|
||||
child: state.messages.isEmpty
|
||||
? EmptyView(
|
||||
icon: Icons.forum_outlined,
|
||||
message: l10n.chatEmpty,
|
||||
)
|
||||
: ListView.builder(
|
||||
// الأحدث أسفل، والقائمة مقلوبة كي تبقى عند آخر رسالة.
|
||||
reverse: true,
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final msg = state.messages[state.messages.length - 1 - i];
|
||||
return _Bubble(msg: msg, mine: msg.senderId == myId);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bubble extends StatelessWidget {
|
||||
const _Bubble({required this.msg, required this.mine});
|
||||
|
||||
final ChatMessage msg;
|
||||
final bool mine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment:
|
||||
mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: Space.xxs),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.sm,
|
||||
vertical: Space.xs,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: mine
|
||||
? context.colors.primaryContainer
|
||||
: context.tripzColors.surfaceRaised,
|
||||
borderRadius: Radii.card_,
|
||||
border: mine ? null : Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Text(msg.body, style: context.texts.bodyMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/api/api_failure.dart';
|
||||
import '../data/driver_repository.dart';
|
||||
import 'onboarding_state.dart';
|
||||
|
||||
/// تسجيل السائق حتى الاعتماد.
|
||||
///
|
||||
/// **الخطوة تُشتقّ من حالة الخادم لا من تقدّم محلّي**: سائق يعيد تثبيت
|
||||
/// التطبيق يجب أن يعود إلى حيث وقف، لا أن يبدأ من الصفر.
|
||||
class OnboardingCubit extends Cubit<OnboardingState> {
|
||||
OnboardingCubit(this._repo) : super(const OnboardingState());
|
||||
|
||||
final DriverRepository _repo;
|
||||
|
||||
Future<void> load() async {
|
||||
emit(state.copyWith(step: OnboardingStep.loading, clearFailure: true));
|
||||
try {
|
||||
final driver = await _repo.me();
|
||||
if (isClosed) return;
|
||||
|
||||
if (driver == null) {
|
||||
emit(state.copyWith(step: OnboardingStep.apply, driver: null));
|
||||
return;
|
||||
}
|
||||
if (driver.isApproved) {
|
||||
emit(state.copyWith(step: OnboardingStep.approved, driver: driver));
|
||||
return;
|
||||
}
|
||||
|
||||
// الملف ناقص ما لم يُدخل الاسم العربي — أول ما يطلبه الخادم.
|
||||
if (driver.nameArabic == null || driver.nameArabic!.isEmpty) {
|
||||
emit(state.copyWith(step: OnboardingStep.profile, driver: driver));
|
||||
return;
|
||||
}
|
||||
|
||||
final vehicles = await _repo.vehicles();
|
||||
if (isClosed) return;
|
||||
if (vehicles.isEmpty) {
|
||||
emit(state.copyWith(
|
||||
step: OnboardingStep.vehicle,
|
||||
driver: driver,
|
||||
vehicles: vehicles,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
final reqs = await _repo.requirements();
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(
|
||||
// اكتملت الوثائق ولم يُعتمد بعد: مراجعة بشرية جارية.
|
||||
step: reqs.complete ? OnboardingStep.pending : OnboardingStep.documents,
|
||||
driver: driver,
|
||||
vehicles: vehicles,
|
||||
requirements: reqs.items,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(
|
||||
step: OnboardingStep.apply,
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> apply({required String vehicleMake, required String serviceClass}) =>
|
||||
_run(() => _repo.apply(vehicleMake: vehicleMake, serviceClass: serviceClass));
|
||||
|
||||
Future<void> saveProfile({
|
||||
required String nameArabic,
|
||||
required String nationalNumber,
|
||||
String? gender,
|
||||
}) =>
|
||||
_run(() => _repo.setProfile(
|
||||
nameArabic: nameArabic,
|
||||
nationalNumber: nationalNumber,
|
||||
gender: gender,
|
||||
));
|
||||
|
||||
Future<void> addVehicle({
|
||||
required String make,
|
||||
required String model,
|
||||
required int year,
|
||||
required String color,
|
||||
required String plate,
|
||||
}) =>
|
||||
_run(() => _repo.addVehicle(
|
||||
make: make,
|
||||
model: model,
|
||||
year: year,
|
||||
color: color,
|
||||
plate: plate,
|
||||
));
|
||||
|
||||
Future<void> uploadDocument({
|
||||
required String filePath,
|
||||
required String type,
|
||||
required String side,
|
||||
}) =>
|
||||
_run(() => _repo.uploadDocument(
|
||||
filePath: filePath,
|
||||
type: type,
|
||||
side: side,
|
||||
));
|
||||
|
||||
/// كل فعل ينتهي بإعادة قراءة الحالة من الخادم — فلا يتقدّم التطبيق خطوةً
|
||||
/// لم يقبلها الخادم فعلاً.
|
||||
Future<void> _run(Future<dynamic> Function() action) async {
|
||||
emit(state.copyWith(busy: true, clearFailure: true));
|
||||
try {
|
||||
await action();
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(busy: false));
|
||||
await load();
|
||||
} on ApiException catch (e) {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(busy: false, failure: e.toApiFailure()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../core/api/api_failure.dart';
|
||||
import '../data/models/document_requirement.dart';
|
||||
import '../data/models/driver_profile.dart';
|
||||
import '../data/models/vehicle.dart';
|
||||
|
||||
/// خطوات تسجيل السائق بالترتيب الميداني (docs/39 §2): تقديم ← ملف ←
|
||||
/// مركبة ← وثائق ← انتظار الاعتماد.
|
||||
enum OnboardingStep { loading, apply, profile, vehicle, documents, pending, approved }
|
||||
|
||||
class OnboardingState extends Equatable {
|
||||
const OnboardingState({
|
||||
this.step = OnboardingStep.loading,
|
||||
this.driver,
|
||||
this.vehicles = const [],
|
||||
this.requirements = const [],
|
||||
this.busy = false,
|
||||
this.failure,
|
||||
});
|
||||
|
||||
final OnboardingStep step;
|
||||
final DriverProfile? driver;
|
||||
final List<Vehicle> vehicles;
|
||||
final List<DocumentRequirement> requirements;
|
||||
final bool busy;
|
||||
final ApiFailure? failure;
|
||||
|
||||
OnboardingState copyWith({
|
||||
OnboardingStep? step,
|
||||
DriverProfile? driver,
|
||||
List<Vehicle>? vehicles,
|
||||
List<DocumentRequirement>? requirements,
|
||||
bool? busy,
|
||||
ApiFailure? failure,
|
||||
bool clearFailure = false,
|
||||
}) {
|
||||
return OnboardingState(
|
||||
step: step ?? this.step,
|
||||
driver: driver ?? this.driver,
|
||||
vehicles: vehicles ?? this.vehicles,
|
||||
requirements: requirements ?? this.requirements,
|
||||
busy: busy ?? this.busy,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[step, driver, vehicles, requirements, busy, failure];
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import 'models/document_requirement.dart';
|
||||
import 'models/driver_profile.dart';
|
||||
import 'models/vehicle.dart';
|
||||
|
||||
class DriverRepository {
|
||||
DriverRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
/// `null` حين لا سجلّ سائق بعد — 404 هنا حالة طبيعية لا عطل.
|
||||
Future<DriverProfile?> me() async {
|
||||
try {
|
||||
final res = await _api.get<Map<String, dynamic>>('/drivers/me');
|
||||
return DriverProfile.fromJson(res);
|
||||
} on ApiException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<DriverProfile> apply({
|
||||
required String vehicleMake,
|
||||
required String serviceClass,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>('/drivers/apply', body: {
|
||||
'vehicle_make': vehicleMake,
|
||||
'service_class': serviceClass,
|
||||
});
|
||||
return DriverProfile.fromJson(res);
|
||||
}
|
||||
|
||||
Future<void> setProfile({
|
||||
required String nameArabic,
|
||||
required String nationalNumber,
|
||||
String? gender,
|
||||
String? birthdate,
|
||||
String? address,
|
||||
String? licenseType,
|
||||
String? licenseExpiry,
|
||||
}) {
|
||||
return _api.patch<Map<String, dynamic>>('/drivers/profile', body: {
|
||||
'name_arabic': nameArabic,
|
||||
'national_number': nationalNumber,
|
||||
'gender': ?gender,
|
||||
'birthdate': ?birthdate,
|
||||
'address': ?address,
|
||||
'license_type': ?licenseType,
|
||||
'license_expiry': ?licenseExpiry,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Vehicle>> vehicles() async {
|
||||
final res = await _api.get<List<dynamic>>('/vehicles/mine');
|
||||
return res
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Vehicle.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Vehicle> addVehicle({
|
||||
required String make,
|
||||
required String model,
|
||||
required int year,
|
||||
required String color,
|
||||
required String plate,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>('/vehicles', body: {
|
||||
'make': make,
|
||||
'model': model,
|
||||
'year': year,
|
||||
'color': color,
|
||||
'plate': plate,
|
||||
});
|
||||
return Vehicle.fromJson(res);
|
||||
}
|
||||
|
||||
Future<({bool complete, List<DocumentRequirement> items})> requirements() async {
|
||||
final res = await _api.get<Map<String, dynamic>>(
|
||||
'/drivers/documents/requirements',
|
||||
);
|
||||
final raw = res['items'];
|
||||
return (
|
||||
complete: res['complete'] as bool? ?? false,
|
||||
items: raw is List
|
||||
? raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(DocumentRequirement.fromJson)
|
||||
.toList(growable: false)
|
||||
: const <DocumentRequirement>[],
|
||||
);
|
||||
}
|
||||
|
||||
/// رفع وثيقة. الاعتماد **يقع على الخادم تلقائياً** حين تكتمل كل الوثائق —
|
||||
/// التطبيق لا يعتمد أحداً.
|
||||
Future<void> uploadDocument({
|
||||
required String filePath,
|
||||
required String type,
|
||||
required String side,
|
||||
}) {
|
||||
return _api.upload<Map<String, dynamic>>(
|
||||
'/drivers/documents',
|
||||
filePath: filePath,
|
||||
fields: {'type': type, 'side': side},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// وثيقة مطلوبة وحالتها — من `GET /drivers/documents/requirements`.
|
||||
/// الخادم يعتمد السائق **تلقائياً** حين تصير كلها `approved`.
|
||||
class DocumentRequirement extends Equatable {
|
||||
const DocumentRequirement({
|
||||
required this.type,
|
||||
required this.nameAr,
|
||||
required this.side,
|
||||
required this.status,
|
||||
this.documentId,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final String nameAr;
|
||||
|
||||
/// وجه المستند: `front` أو `back` — بعضها يحتاج الوجهين.
|
||||
final String side;
|
||||
|
||||
/// `missing` · `pending` · `approved` · `rejected`.
|
||||
final String status;
|
||||
|
||||
final String? documentId;
|
||||
|
||||
factory DocumentRequirement.fromJson(Map<String, dynamic> json) =>
|
||||
DocumentRequirement(
|
||||
type: json['type'] as String? ?? '',
|
||||
nameAr: json['name_ar'] as String? ?? '',
|
||||
side: json['side'] as String? ?? 'front',
|
||||
status: json['status'] as String? ?? 'missing',
|
||||
documentId: json['document_id'] as String?,
|
||||
);
|
||||
|
||||
bool get isMissing => status == 'missing' || status == 'rejected';
|
||||
bool get isApproved => status == 'approved';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type, side, status];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// حالة اعتماد السائق كما يعرّفها الخادم: `pending` · `approved` · `rejected`.
|
||||
enum VerificationStatus {
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
|
||||
/// لا سجلّ سائق بعد — لم يقدّم أصلاً.
|
||||
none;
|
||||
|
||||
static VerificationStatus parse(String? raw) => switch (raw) {
|
||||
'approved' => VerificationStatus.approved,
|
||||
'rejected' => VerificationStatus.rejected,
|
||||
'pending' => VerificationStatus.pending,
|
||||
_ => VerificationStatus.none,
|
||||
};
|
||||
}
|
||||
|
||||
class DriverProfile extends Equatable {
|
||||
const DriverProfile({
|
||||
required this.id,
|
||||
required this.verification,
|
||||
this.serviceClass = 'economy',
|
||||
this.nameArabic,
|
||||
this.online = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final VerificationStatus verification;
|
||||
final String serviceClass;
|
||||
final String? nameArabic;
|
||||
final bool online;
|
||||
|
||||
factory DriverProfile.fromJson(Map<String, dynamic> json) => DriverProfile(
|
||||
id: json['id'] as String? ?? '',
|
||||
verification:
|
||||
VerificationStatus.parse(json['verification_status'] as String?),
|
||||
serviceClass: json['service_class'] as String? ?? 'economy',
|
||||
nameArabic: json['name_arabic'] as String?,
|
||||
online: json['online'] as bool? ?? false,
|
||||
);
|
||||
|
||||
bool get isApproved => verification == VerificationStatus.approved;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, verification, serviceClass, online];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class Vehicle extends Equatable {
|
||||
const Vehicle({
|
||||
required this.id,
|
||||
this.make,
|
||||
this.model,
|
||||
this.year,
|
||||
this.color,
|
||||
this.plate,
|
||||
this.isDefault = false,
|
||||
this.status = 'pending',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? make;
|
||||
final String? model;
|
||||
final int? year;
|
||||
final String? color;
|
||||
final String? plate;
|
||||
|
||||
/// أول مركبة تصير الافتراضية تلقائياً على الخادم (docs/38 §5).
|
||||
final bool isDefault;
|
||||
|
||||
final String status;
|
||||
|
||||
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||
id: json['id'] as String? ?? '',
|
||||
make: json['make'] as String?,
|
||||
model: json['model'] as String?,
|
||||
year: (json['year'] as num?)?.toInt(),
|
||||
color: json['color'] as String?,
|
||||
plate: json['plate'] as String?,
|
||||
isDefault: json['is_default'] as bool? ?? false,
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
);
|
||||
|
||||
String get label => [make, model, year?.toString()]
|
||||
.whereType<String>()
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' ');
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, plate, isDefault, status];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/di.dart';
|
||||
import '../../../core/ui/view_status.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../cubit/onboarding_cubit.dart';
|
||||
import '../cubit/onboarding_state.dart';
|
||||
import 'steps/apply_step.dart';
|
||||
import 'steps/documents_step.dart';
|
||||
import 'steps/pending_step.dart';
|
||||
import 'steps/profile_step.dart';
|
||||
import 'steps/vehicle_step.dart';
|
||||
|
||||
/// مضيف تسجيل السائق. الخطوة تُشتقّ من حالة الخادم في كل مرة — سائق يعيد
|
||||
/// تثبيت التطبيق يعود إلى حيث وقف لا إلى البداية.
|
||||
class OnboardingPage extends StatefulWidget {
|
||||
const OnboardingPage({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingPage> createState() => _OnboardingPageState();
|
||||
}
|
||||
|
||||
class _OnboardingPageState extends State<OnboardingPage> {
|
||||
late final OnboardingCubit _cubit = sl<OnboardingCubit>()..load();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocBuilder<OnboardingCubit, OnboardingState>(
|
||||
builder: (context, state) => switch (state.step) {
|
||||
OnboardingStep.loading => const TripzScaffold(
|
||||
showAppBar: false,
|
||||
status: ViewStatus.loading,
|
||||
child: SizedBox.shrink(),
|
||||
),
|
||||
OnboardingStep.apply => ApplyStep(state: state),
|
||||
OnboardingStep.profile => ProfileStep(state: state),
|
||||
OnboardingStep.vehicle => VehicleStep(state: state),
|
||||
OnboardingStep.documents => DocumentsStep(state: state),
|
||||
OnboardingStep.pending => const PendingStep(approved: false),
|
||||
OnboardingStep.approved => const PendingStep(approved: true),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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/form_header.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../../core/ui/tripz_text_field.dart';
|
||||
import '../../cubit/onboarding_cubit.dart';
|
||||
import '../../cubit/onboarding_state.dart';
|
||||
|
||||
class ApplyStep extends StatefulWidget {
|
||||
const ApplyStep({super.key, required this.state});
|
||||
|
||||
final OnboardingState state;
|
||||
|
||||
@override
|
||||
State<ApplyStep> createState() => _ApplyStepState();
|
||||
}
|
||||
|
||||
class _ApplyStepState extends State<ApplyStep> {
|
||||
final _make = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_make.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final cubit = context.read<OnboardingCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.obApplySubmit,
|
||||
loading: widget.state.busy,
|
||||
onPressed: () => cubit.apply(
|
||||
vehicleMake: _make.text.trim(),
|
||||
// فئة الخدمة الافتراضية؛ يعدّلها الأدمن لاحقاً حسب المركبة المعتمدة.
|
||||
serviceClass: 'economy',
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
FormHeader(
|
||||
icon: Icons.badge_outlined,
|
||||
title: l10n.obApplyTitle,
|
||||
lead: l10n.obApplyLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzTextField(
|
||||
controller: _make,
|
||||
label: l10n.obVehicleMake,
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/design/tokens.dart';
|
||||
import '../../../../core/design/tripz_colors.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/ui/form_header.dart';
|
||||
import '../../../../core/ui/tripz_card.dart';
|
||||
import '../../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../cubit/onboarding_cubit.dart';
|
||||
import '../../cubit/onboarding_state.dart';
|
||||
import '../../data/models/document_requirement.dart';
|
||||
|
||||
/// قائمة الوثائق المطلوبة وحالتها. **الخادم يعتمد السائق تلقائياً** حين
|
||||
/// تصير كلها `approved` — التطبيق لا يعتمد أحداً ولا يخمّن الاكتمال.
|
||||
class DocumentsStep extends StatelessWidget {
|
||||
const DocumentsStep({super.key, required this.state});
|
||||
|
||||
final OnboardingState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
FormHeader(
|
||||
icon: Icons.folder_open_outlined,
|
||||
title: l10n.obDocsTitle,
|
||||
lead: l10n.obDocsLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
for (final req in state.requirements) ...[
|
||||
_DocTile(req: req, busy: state.busy),
|
||||
const SizedBox(height: Space.xs),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DocTile extends StatelessWidget {
|
||||
const _DocTile({required this.req, required this.busy});
|
||||
|
||||
final DocumentRequirement req;
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final colors = context.tripzColors;
|
||||
|
||||
final (label, color) = switch (req.status) {
|
||||
'approved' => (l10n.obDocApproved, colors.success),
|
||||
'pending' => (l10n.obDocPending, colors.warning),
|
||||
'rejected' => (l10n.obDocRejected, colors.danger),
|
||||
_ => (l10n.obDocMissing, context.colors.onSurfaceVariant),
|
||||
};
|
||||
|
||||
return TripzCard(
|
||||
onTap: req.isMissing && !busy ? () => _pick(context) : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
req.isApproved
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.upload_file_outlined,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: Space.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(req.nameAr, style: context.texts.bodyMedium),
|
||||
Text(
|
||||
'${req.side == 'back' ? l10n.obDocSideBack : l10n.obDocSideFront} · $label',
|
||||
style: context.texts.bodySmall?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (req.isMissing)
|
||||
Text(
|
||||
l10n.obDocUpload,
|
||||
style: context.texts.labelLarge?.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pick(BuildContext context) async {
|
||||
final cubit = context.read<OnboardingCubit>();
|
||||
// الكاميرا لا المعرض: صورة وثيقة تُلتقط الآن أصعب تزويراً من ملف محفوظ.
|
||||
final file = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80,
|
||||
maxWidth: 1600,
|
||||
);
|
||||
if (file == null) return;
|
||||
await cubit.uploadDocument(
|
||||
filePath: file.path,
|
||||
type: req.type,
|
||||
side: req.side,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/session/session_cubit.dart';
|
||||
import '../../../../core/ui/form_header.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../cubit/onboarding_cubit.dart';
|
||||
|
||||
/// انتظار المراجعة، أو الاعتماد.
|
||||
///
|
||||
/// عند الاعتماد **لا بد من إعادة دخول**: الدور يتغيّر على الخادم بعد
|
||||
/// `approve`، والتوكن القديم ما زال يحمل الدور القديم — فتفشل نقاط السائق
|
||||
/// بـ403 (مصيدة docs/38 §5). فالخروج هنا فعلٌ مقصود لا خطأ.
|
||||
class PendingStep extends StatelessWidget {
|
||||
const PendingStep({super.key, required this.approved});
|
||||
|
||||
final bool approved;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: approved
|
||||
? TripzButton.primary(
|
||||
label: l10n.obRelogin,
|
||||
onPressed: context.read<SessionCubit>().logout,
|
||||
)
|
||||
: TripzButton.secondary(
|
||||
label: l10n.obPendingRefresh,
|
||||
expanded: true,
|
||||
onPressed: context.read<OnboardingCubit>().load,
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xxl),
|
||||
Icon(
|
||||
approved ? Icons.verified_rounded : Icons.hourglass_top_rounded,
|
||||
size: 56,
|
||||
color: approved
|
||||
? context.tripzColors.success
|
||||
: context.tripzColors.warning,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
FormHeader(
|
||||
icon: approved ? Icons.verified_outlined : Icons.schedule_rounded,
|
||||
title: approved ? l10n.obApprovedTitle : l10n.obPendingTitle,
|
||||
lead: approved ? l10n.obApprovedLead : l10n.obPendingLead,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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/form_header.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../../core/ui/tripz_text_field.dart';
|
||||
import '../../cubit/onboarding_cubit.dart';
|
||||
import '../../cubit/onboarding_state.dart';
|
||||
|
||||
class ProfileStep extends StatefulWidget {
|
||||
const ProfileStep({super.key, required this.state});
|
||||
|
||||
final OnboardingState state;
|
||||
|
||||
@override
|
||||
State<ProfileStep> createState() => _ProfileStepState();
|
||||
}
|
||||
|
||||
class _ProfileStepState extends State<ProfileStep> {
|
||||
final _name = TextEditingController();
|
||||
final _national = TextEditingController();
|
||||
String _gender = 'male';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_national.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final cubit = context.read<OnboardingCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.actionContinue,
|
||||
loading: widget.state.busy,
|
||||
onPressed: () => cubit.saveProfile(
|
||||
nameArabic: _name.text.trim(),
|
||||
nationalNumber: _national.text.trim(),
|
||||
gender: _gender,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
FormHeader(
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: l10n.obProfileTitle,
|
||||
lead: l10n.obApplyLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzTextField(
|
||||
controller: _name,
|
||||
label: l10n.obNameArabic,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
// الرقم الوطني يُشفَّر على الخادم مع فهرس أعمى — يُدخل مرة واحدة.
|
||||
TripzTextField(
|
||||
controller: _national,
|
||||
label: l10n.obNationalNumber,
|
||||
numeric: true,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
Text(l10n.obGender, style: Theme.of(context).textTheme.titleSmall),
|
||||
RadioGroup<String>(
|
||||
groupValue: _gender,
|
||||
onChanged: (v) => setState(() => _gender = v ?? 'male'),
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile(
|
||||
value: 'male',
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.obGenderMale),
|
||||
),
|
||||
RadioListTile(
|
||||
value: 'female',
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.obGenderFemale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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/form_header.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../../core/ui/tripz_text_field.dart';
|
||||
import '../../cubit/onboarding_cubit.dart';
|
||||
import '../../cubit/onboarding_state.dart';
|
||||
|
||||
class VehicleStep extends StatefulWidget {
|
||||
const VehicleStep({super.key, required this.state});
|
||||
|
||||
final OnboardingState state;
|
||||
|
||||
@override
|
||||
State<VehicleStep> createState() => _VehicleStepState();
|
||||
}
|
||||
|
||||
class _VehicleStepState extends State<VehicleStep> {
|
||||
final _make = TextEditingController();
|
||||
final _model = TextEditingController();
|
||||
final _year = TextEditingController();
|
||||
final _color = TextEditingController();
|
||||
final _plate = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_make, _model, _year, _color, _plate]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final cubit = context.read<OnboardingCubit>();
|
||||
return TripzScaffold(
|
||||
showAppBar: false,
|
||||
bottomAction: TripzButton.primary(
|
||||
label: l10n.actionContinue,
|
||||
loading: widget.state.busy,
|
||||
onPressed: () => cubit.addVehicle(
|
||||
make: _make.text.trim(),
|
||||
model: _model.text.trim(),
|
||||
year: int.tryParse(_year.text.trim()) ?? 0,
|
||||
color: _color.text.trim(),
|
||||
plate: _plate.text.trim(),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
const SizedBox(height: Space.xl),
|
||||
FormHeader(
|
||||
icon: Icons.directions_car_outlined,
|
||||
title: l10n.obVehicleTitle,
|
||||
lead: l10n.obApplyLead,
|
||||
),
|
||||
const SizedBox(height: Space.lg),
|
||||
TripzTextField(controller: _make, label: l10n.obVehicleMake),
|
||||
const SizedBox(height: Space.md),
|
||||
TripzTextField(controller: _model, label: l10n.obVehicleModel),
|
||||
const SizedBox(height: Space.md),
|
||||
TripzTextField(
|
||||
controller: _year,
|
||||
label: l10n.obVehicleYear,
|
||||
numeric: true,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: Space.md),
|
||||
TripzTextField(controller: _color, label: l10n.obVehicleColor),
|
||||
const SizedBox(height: Space.md),
|
||||
// اللوحة تُشفَّر على الخادم — تُدخل كما هي على المركبة.
|
||||
TripzTextField(controller: _plate, label: l10n.obVehiclePlate),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ class ProfileState {
|
||||
|
||||
final ViewStatus status;
|
||||
final bool saving;
|
||||
final AuthFailure? failure;
|
||||
final ApiFailure? failure;
|
||||
final AppUser? user;
|
||||
|
||||
ProfileState copyWith({
|
||||
ViewStatus? status,
|
||||
bool? saving,
|
||||
AuthFailure? failure,
|
||||
ApiFailure? failure,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
}) {
|
||||
@@ -53,7 +53,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
emit(state.copyWith(saving: false, user: user));
|
||||
return user;
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
|
||||
emit(state.copyWith(saving: false, failure: e.toApiFailure()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/design/tokens.dart';
|
||||
import '../../../../core/design/tripz_colors.dart';
|
||||
import '../../../../core/build_config.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/router.dart';
|
||||
import '../../../../core/tenant/feature_gate.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../cubit/duty_cubit.dart';
|
||||
import '../../data/models/trip.dart';
|
||||
@@ -19,6 +23,7 @@ class ActiveDutySheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final cubit = context.read<DutyCubit>();
|
||||
|
||||
return Column(
|
||||
@@ -31,6 +36,15 @@ class ActiveDutySheet extends StatelessWidget {
|
||||
// السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة.
|
||||
FareSummary(trip: trip, forDriver: true),
|
||||
],
|
||||
if (Features.chat && context.tenantHas('chat')) ...[
|
||||
const SizedBox(height: Space.sm),
|
||||
TripzButton.secondary(
|
||||
label: l10n.chatTitle,
|
||||
icon: Icons.forum_outlined,
|
||||
expanded: true,
|
||||
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Space.md),
|
||||
TripzButton.primary(
|
||||
label: _actionLabel(context, cubit),
|
||||
|
||||
@@ -81,6 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -169,6 +177,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -251,6 +291,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -429,6 +477,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+17"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
intaleq_maps:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -40,6 +40,9 @@ dependencies:
|
||||
firebase_core: ^4.4.0
|
||||
firebase_messaging: ^16.1.1
|
||||
|
||||
# تسجيل السائق: تصوير الوثائق
|
||||
image_picker: ^1.2.1
|
||||
|
||||
# م3: الأذونات وبصمة الجهاز
|
||||
permission_handler: ^12.0.1
|
||||
device_info_plus: ^12.3.0
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""مفاتيح ترجمة تخصّ تطبيق السائق وحده.
|
||||
|
||||
التطبيقان توأمان (docs/23 §0.1) ونصوصهما المشتركة تُزامَن بـ`rsync` من
|
||||
الراكب — وهو ينسخ ملفَّي ARB كما هما فيمحو ما يخصّ السائق. لذلك يُعاد
|
||||
تشغيل هذا السكربت بعد كل مزامنة، ثم `flutter gen-l10n`.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
BASE = os.path.join(os.path.dirname(__file__), '..', 'lib', 'core', 'l10n')
|
||||
|
||||
EXTRA = {
|
||||
'ar': {
|
||||
"obApplyTitle": "سجّل كسائق",
|
||||
"obApplyLead": "خطوات قصيرة ثم تبدأ العمل",
|
||||
"obVehicleMake": "شركة السيارة",
|
||||
"obServiceClass": "نوع الخدمة",
|
||||
"obApplySubmit": "ابدأ التسجيل",
|
||||
"obProfileTitle": "بياناتك",
|
||||
"obNameArabic": "الاسم كما في الهوية",
|
||||
"obNationalNumber": "الرقم الوطني",
|
||||
"obGender": "الجنس",
|
||||
"obGenderMale": "ذكر",
|
||||
"obGenderFemale": "أنثى",
|
||||
"obVehicleTitle": "مركبتك",
|
||||
"obVehicleModel": "الطراز",
|
||||
"obVehicleYear": "سنة الصنع",
|
||||
"obVehicleColor": "اللون",
|
||||
"obVehiclePlate": "رقم اللوحة",
|
||||
"obDocsTitle": "وثائقك",
|
||||
"obDocsLead": "صوّر كل وثيقة بوضوح — الاعتماد يتم بعد مراجعتها",
|
||||
"obDocMissing": "مطلوبة",
|
||||
"obDocPending": "قيد المراجعة",
|
||||
"obDocApproved": "مقبولة",
|
||||
"obDocRejected": "مرفوضة — أعد الرفع",
|
||||
"obDocSideFront": "الوجه الأمامي",
|
||||
"obDocSideBack": "الوجه الخلفي",
|
||||
"obDocUpload": "رفع",
|
||||
"obPendingTitle": "طلبك قيد المراجعة",
|
||||
"obPendingLead": "سنُعلمك فور اعتماد حسابك. عادةً خلال يوم عمل.",
|
||||
"obPendingRefresh": "تحديث الحالة",
|
||||
"obApprovedTitle": "تم اعتمادك",
|
||||
"obApprovedLead": "سجّل الدخول من جديد لتفعيل صلاحيات السائق",
|
||||
"obRelogin": "إعادة تسجيل الدخول",
|
||||
"phoneTitle": "أهلاً بك، كابتن",
|
||||
"profileLead": "اسمك يظهر للراكب عند الرحلة.",
|
||||
"historyEmpty": "لا رحلات بعد — اتصل لتصلك الطلبات",
|
||||
},
|
||||
'en': {
|
||||
"obApplyTitle": "Register as a driver",
|
||||
"obApplyLead": "A few short steps, then you're working",
|
||||
"obVehicleMake": "Car make",
|
||||
"obServiceClass": "Service class",
|
||||
"obApplySubmit": "Start registration",
|
||||
"obProfileTitle": "Your details",
|
||||
"obNameArabic": "Name as on your ID",
|
||||
"obNationalNumber": "National ID number",
|
||||
"obGender": "Gender",
|
||||
"obGenderMale": "Male",
|
||||
"obGenderFemale": "Female",
|
||||
"obVehicleTitle": "Your vehicle",
|
||||
"obVehicleModel": "Model",
|
||||
"obVehicleYear": "Year",
|
||||
"obVehicleColor": "Colour",
|
||||
"obVehiclePlate": "Plate number",
|
||||
"obDocsTitle": "Your documents",
|
||||
"obDocsLead": "Photograph each document clearly — approval follows review",
|
||||
"obDocMissing": "Required",
|
||||
"obDocPending": "Under review",
|
||||
"obDocApproved": "Approved",
|
||||
"obDocRejected": "Rejected — upload again",
|
||||
"obDocSideFront": "Front",
|
||||
"obDocSideBack": "Back",
|
||||
"obDocUpload": "Upload",
|
||||
"obPendingTitle": "Your application is under review",
|
||||
"obPendingLead": "We'll notify you as soon as it's approved. Usually within a business day.",
|
||||
"obPendingRefresh": "Refresh status",
|
||||
"obApprovedTitle": "You're approved",
|
||||
"obApprovedLead": "Sign in again to activate your driver permissions",
|
||||
"obRelogin": "Sign in again",
|
||||
"phoneTitle": "Welcome, captain",
|
||||
"profileLead": "Your name is shown to the rider during a trip.",
|
||||
"historyEmpty": "No trips yet — go online to receive requests",
|
||||
},
|
||||
}
|
||||
|
||||
for loc, extra in EXTRA.items():
|
||||
path = os.path.join(BASE, f'app_{loc}.arb')
|
||||
data = json.load(open(path))
|
||||
data.update(extra)
|
||||
json.dump(data, open(path, 'w'), ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"أُضيف {len(EXTRA['ar'])} مفتاحاً خاصاً بتطبيق السائق")
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# مزامنة الكود المشترك من rider_new إلى driver_new.
|
||||
#
|
||||
# التطبيقان توأمان في البنية ومختلفان في الميزات (docs/23 §0.1)، ولا حزمة
|
||||
# مشتركة بينهما لأن docs/23 §1 و docs/26 §10 يفرضان core/ داخل كل تطبيق.
|
||||
# البديل الوحيد إذن مزامنة منضبطة — وهذا الملف هو تعريفها الوحيد.
|
||||
#
|
||||
# التشغيل: bash tool/sync_from_rider.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# ملفات يملكها السائق ولا تُنسخ من الراكب أبداً
|
||||
OWN=(
|
||||
"core/config.dart" # appRole = driver
|
||||
"core/build_config.dart" # اسم التطبيق
|
||||
"core/di.dart" # DutyCubit + OnboardingCubit + PayoutCubit
|
||||
"core/router.dart" # DutyPage + EarningsPage + بوابة التسجيل
|
||||
"core/l10n/app_ar.arb"
|
||||
"core/l10n/app_en.arb"
|
||||
"core/l10n/gen"
|
||||
"features/onboarding" # تسجيل السائق — لا مقابل له عند الراكب
|
||||
"features/trip/cubit/duty_cubit.dart"
|
||||
"features/trip/cubit/duty_state.dart"
|
||||
"features/trip/view/duty_page.dart"
|
||||
"features/trip/view/widgets/duty_toggle.dart"
|
||||
"features/trip/view/widgets/offer_sheet.dart"
|
||||
"features/trip/view/widgets/active_duty_sheet.dart"
|
||||
"features/wallet/cubit/payout_cubit.dart"
|
||||
"features/wallet/view/earnings_page.dart"
|
||||
"features/wallet/view/widgets/payout_sheet.dart"
|
||||
)
|
||||
|
||||
# ملفات تخصّ الراكب ولا مكان لها عند السائق
|
||||
RIDER_ONLY=(
|
||||
"features/trip/cubit/ride_cubit.dart"
|
||||
"features/trip/cubit/ride_state.dart"
|
||||
"features/trip/view/ride_page.dart"
|
||||
"features/trip/view/widgets/planner_sheet.dart"
|
||||
"features/trip/view/widgets/confirm_sheet.dart"
|
||||
"features/trip/view/widgets/searching_sheet.dart"
|
||||
"features/trip/view/widgets/route_timeline.dart"
|
||||
"features/trip/view/widgets/ride_type_picker.dart"
|
||||
"features/trip/view/widgets/center_pin.dart"
|
||||
"features/trip/view/widgets/active_trip_sheet.dart"
|
||||
"features/trip/view/widgets/cancel_sheet.dart"
|
||||
"features/wallet/view/wallet_page.dart"
|
||||
)
|
||||
|
||||
args=()
|
||||
for p in "${OWN[@]}" "${RIDER_ONLY[@]}"; do args+=(--exclude "$p"); done
|
||||
|
||||
rsync -a "${args[@]}" ../rider_new/lib/ lib/
|
||||
|
||||
# نصوص السائق تُعاد بعد كل مزامنة (ملفا ARB مستثنيان لكن قد يتغيّر المشترك)
|
||||
python3 tool/driver_l10n_extra.py
|
||||
echo "✅ تمّت المزامنة — شغّل: flutter gen-l10n && flutter analyze"
|
||||
@@ -47,6 +47,22 @@ class ApiClient {
|
||||
|
||||
Future<T> delete<T>(String path) => _send(() => _dio.delete<T>(path));
|
||||
|
||||
/// رفع ملف — الحقل اسمه `file` كما يتوقّعه `FileInterceptor` على الخادم.
|
||||
/// يمرّ بنفس الـinterceptor فيحمل التوكن والمستأجر والجهاز.
|
||||
Future<T> upload<T>(
|
||||
String path, {
|
||||
required String filePath,
|
||||
Map<String, dynamic> fields = const {},
|
||||
}) {
|
||||
return _send(() async {
|
||||
final form = FormData.fromMap({
|
||||
...fields,
|
||||
'file': await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
return _dio.post<T>(path, data: form);
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _send<T>(Future<Response<T>> Function() call) async {
|
||||
try {
|
||||
final res = await call();
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'api_exception.dart';
|
||||
/// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3).
|
||||
/// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى
|
||||
/// الشاشة بلا منطق، والنص بلغتين.
|
||||
enum AuthFailure {
|
||||
enum ApiFailure {
|
||||
/// رمز خاطئ أو منتهي الصلاحية.
|
||||
invalidCode,
|
||||
|
||||
@@ -22,17 +22,17 @@ enum AuthFailure {
|
||||
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;
|
||||
extension ApiFailureX on ApiException {
|
||||
ApiFailure toApiFailure() {
|
||||
if (isRateLimited) return ApiFailure.rateLimited;
|
||||
if (kind == ApiErrorKind.network) return ApiFailure.network;
|
||||
if (kind == ApiErrorKind.timeout) return ApiFailure.timeout;
|
||||
if (isUnauthorized) {
|
||||
// الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده.
|
||||
return message.contains('Too many')
|
||||
? AuthFailure.tooManyAttempts
|
||||
: AuthFailure.invalidCode;
|
||||
? ApiFailure.tooManyAttempts
|
||||
: ApiFailure.invalidCode;
|
||||
}
|
||||
return AuthFailure.server;
|
||||
return ApiFailure.server;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ class Features {
|
||||
|
||||
static const bool wallet = true;
|
||||
static const bool chat = true;
|
||||
static const bool calls = true;
|
||||
|
||||
/// WebRTC غير مبنيّ بعد — الخادم يدعمه (`call:*` في docs/38 §9) لكن
|
||||
/// التطبيق لا. يبقى مطفأً حتى يُبنى ويُختبر على جهاز حقيقي.
|
||||
static const bool calls = false;
|
||||
static const bool rideTypes = true;
|
||||
static const bool coupons = true;
|
||||
static const bool geofence = false;
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../features/auth/cubit/login_cubit.dart';
|
||||
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
import '../features/chat/cubit/chat_cubit.dart';
|
||||
import '../features/chat/data/chat_repository.dart';
|
||||
import '../features/profile/data/profile_repository.dart';
|
||||
|
||||
import '../features/rating/cubit/rating_cubit.dart';
|
||||
@@ -104,6 +106,12 @@ Future<void> setupInjector() async {
|
||||
sl.registerSingleton<RatingRepository>(RatingRepository(sl()));
|
||||
sl.registerFactory<RatingCubit>(() => RatingCubit(sl()));
|
||||
|
||||
// الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5).
|
||||
if (Features.chat) {
|
||||
sl.registerSingleton<ChatRepository>(ChatRepository(sl()));
|
||||
sl.registerFactory<ChatCubit>(() => ChatCubit(sl()));
|
||||
}
|
||||
|
||||
// المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5).
|
||||
if (Features.wallet) {
|
||||
sl.registerSingleton<WalletRepository>(WalletRepository(sl()));
|
||||
|
||||
@@ -188,5 +188,10 @@
|
||||
"aboutVersion": "الإصدار",
|
||||
"notifTitle": "الإشعارات",
|
||||
"notifEnable": "تفعيل الإشعارات",
|
||||
"notifDenied": "الإشعارات موقوفة من إعدادات النظام"
|
||||
"notifDenied": "الإشعارات موقوفة من إعدادات النظام",
|
||||
"chatTitle": "المحادثة",
|
||||
"chatHint": "اكتب رسالة",
|
||||
"chatEmpty": "لا رسائل بعد",
|
||||
"rideCoupon": "كود خصم",
|
||||
"rideCouponApply": "تطبيق"
|
||||
}
|
||||
@@ -188,5 +188,10 @@
|
||||
"aboutVersion": "Version",
|
||||
"notifTitle": "Notifications",
|
||||
"notifEnable": "Enable notifications",
|
||||
"notifDenied": "Notifications are disabled in system settings"
|
||||
"notifDenied": "Notifications are disabled in system settings",
|
||||
"chatTitle": "Chat",
|
||||
"chatHint": "Type a message",
|
||||
"chatEmpty": "No messages yet",
|
||||
"rideCoupon": "Promo code",
|
||||
"rideCouponApply": "Apply"
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../features/auth/view/login_page.dart';
|
||||
|
||||
import '../features/chat/view/chat_page.dart';
|
||||
import '../features/profile/view/profile_edit_page.dart';
|
||||
|
||||
import '../features/profile/view/profile_setup_page.dart';
|
||||
@@ -44,6 +45,7 @@ class Routes {
|
||||
static const profileEdit = '/account';
|
||||
static const settings = '/settings';
|
||||
static const support = '/support';
|
||||
static const chat = '/chat';
|
||||
}
|
||||
|
||||
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
||||
@@ -117,6 +119,11 @@ final appRouter = GoRouter(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (context, state) =>
|
||||
ChatPage(tripId: state.uri.queryParameters['trip'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.support,
|
||||
builder: (context, state) => const SupportPage(),
|
||||
|
||||
@@ -12,20 +12,20 @@ import '../l10n/l10n.dart';
|
||||
|
||||
/// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ.
|
||||
/// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`).
|
||||
class AuthFailureText extends StatelessWidget {
|
||||
const AuthFailureText({super.key, required this.failure});
|
||||
class ApiFailureText extends StatelessWidget {
|
||||
const ApiFailureText({super.key, required this.failure});
|
||||
|
||||
final AuthFailure failure;
|
||||
final ApiFailure failure;
|
||||
|
||||
static String messageOf(BuildContext context, AuthFailure failure) {
|
||||
static String messageOf(BuildContext context, ApiFailure 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,
|
||||
ApiFailure.invalidCode => l10n.otpErrInvalid,
|
||||
ApiFailure.tooManyAttempts => l10n.otpErrTooManyAttempts,
|
||||
ApiFailure.rateLimited => l10n.otpErrRateLimited,
|
||||
ApiFailure.network => l10n.errorNetwork,
|
||||
ApiFailure.timeout => l10n.errorTimeout,
|
||||
ApiFailure.server => l10n.errorGeneric,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
// حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً
|
||||
// فوراً وعمّق الحظر.
|
||||
@@ -159,7 +159,7 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
status: LoginStatus.failure,
|
||||
failure: e.toAuthFailure(),
|
||||
failure: e.toApiFailure(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class LoginState extends Equatable {
|
||||
final String phone;
|
||||
|
||||
final bool agreed;
|
||||
final AuthFailure? failure;
|
||||
final ApiFailure? failure;
|
||||
final PhoneError? phoneError;
|
||||
|
||||
/// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل
|
||||
@@ -52,7 +52,7 @@ class LoginState extends Equatable {
|
||||
LoginStatus? status,
|
||||
String? phone,
|
||||
bool? agreed,
|
||||
AuthFailure? failure,
|
||||
ApiFailure? failure,
|
||||
PhoneError? phoneError,
|
||||
int? resendIn,
|
||||
bool? permissionPermanentlyDenied,
|
||||
|
||||
@@ -67,7 +67,7 @@ class _OtpViewState extends State<OtpView> {
|
||||
onCompleted: cubit.submitOtp,
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
const SizedBox(height: Space.md),
|
||||
// العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات
|
||||
// المستخدمَ خمس دقائق (docs/38 §2).
|
||||
|
||||
@@ -57,10 +57,10 @@ class PhoneView extends StatelessWidget {
|
||||
onSubmitted: (_) => cubit.submitPhone(),
|
||||
errorText: state.phoneError == null
|
||||
? null
|
||||
: AuthFailureText.phoneMessageOf(context, state.phoneError!),
|
||||
: ApiFailureText.phoneMessageOf(context, state.phoneError!),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/ui/view_status.dart';
|
||||
import '../data/chat_repository.dart';
|
||||
|
||||
class ChatState {
|
||||
const ChatState({
|
||||
this.status = ViewStatus.idle,
|
||||
this.messages = const [],
|
||||
this.sending = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final ViewStatus status;
|
||||
final List<ChatMessage> messages;
|
||||
final bool sending;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
class ChatCubit extends Cubit<ChatState> {
|
||||
ChatCubit(this._repo) : super(const ChatState());
|
||||
|
||||
final ChatRepository _repo;
|
||||
Timer? _poll;
|
||||
String? _tripId;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_poll?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// استطلاع كل خمس ثوانٍ — الخادم لا يبثّ الرسائل عبر السوكت
|
||||
/// (راجع `ChatRepository`). يتوقّف مع إغلاق الشاشة.
|
||||
Future<void> open(String tripId) async {
|
||||
_tripId = tripId;
|
||||
emit(const ChatState(status: ViewStatus.loading));
|
||||
await _refresh();
|
||||
_poll = Timer.periodic(const Duration(seconds: 5), (_) => _refresh());
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final tripId = _tripId;
|
||||
if (tripId == null || isClosed) return;
|
||||
try {
|
||||
final messages = await _repo.messages(tripId);
|
||||
if (isClosed) return;
|
||||
emit(ChatState(
|
||||
status: ViewStatus.success,
|
||||
messages: messages,
|
||||
sending: state.sending,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
// فشل الاستطلاع لا يمسح ما وصل: الرسائل المعروضة تبقى.
|
||||
if (!isClosed && state.messages.isEmpty) {
|
||||
emit(ChatState(status: ViewStatus.error, error: e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(String body) async {
|
||||
final tripId = _tripId;
|
||||
if (tripId == null || body.trim().isEmpty) return;
|
||||
emit(ChatState(
|
||||
status: state.status,
|
||||
messages: state.messages,
|
||||
sending: true,
|
||||
));
|
||||
try {
|
||||
await _repo.send(tripId, body.trim());
|
||||
await _refresh();
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(ChatState(status: state.status, messages: state.messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../../../core/api/api_client.dart';
|
||||
|
||||
class ChatMessage {
|
||||
const ChatMessage({
|
||||
required this.id,
|
||||
required this.body,
|
||||
required this.senderId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String body;
|
||||
final String senderId;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
|
||||
id: json['id']?.toString() ?? '',
|
||||
body: json['body'] as String? ?? json['message'] as String? ?? '',
|
||||
senderId: json['sender_id']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
/// دردشة الرحلة (docs/38 §11).
|
||||
///
|
||||
/// ⚠️ **لا حدث سوكت للرسائل**: قائمة أحداث الخادم لا تتضمّن الدردشة
|
||||
/// (docs/38 §9)، فالتحديث **باستطلاع دوري** لا ببثّ. هذا قيد الخادم لا
|
||||
/// اختيار تصميمي — ويُلغى فور إضافة الحدث.
|
||||
class ChatRepository {
|
||||
ChatRepository(this._api);
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
Future<List<ChatMessage>> messages(String tripId) async {
|
||||
final res = await _api.get<List<dynamic>>('/trips/$tripId/messages');
|
||||
return res
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ChatMessage.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> send(String tripId, String body) => _api.post<Map<String, dynamic>>(
|
||||
'/trips/$tripId/messages',
|
||||
body: {'body': body},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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/di.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../../core/session/session_cubit.dart';
|
||||
import '../../../core/ui/empty_view.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import '../../../core/ui/tripz_text_field.dart';
|
||||
import '../cubit/chat_cubit.dart';
|
||||
import '../data/chat_repository.dart';
|
||||
|
||||
class ChatPage extends StatefulWidget {
|
||||
const ChatPage({super.key, required this.tripId});
|
||||
|
||||
final String tripId;
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
late final ChatCubit _cubit = sl<ChatCubit>()..open(widget.tripId);
|
||||
final _input = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_input.dispose();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final myId = sl<SessionCubit>().state.user?.id;
|
||||
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocBuilder<ChatCubit, ChatState>(
|
||||
builder: (context, state) => TripzScaffold(
|
||||
title: l10n.chatTitle,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
bottomAction: TripzTextField(
|
||||
controller: _input,
|
||||
hint: l10n.chatHint,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (v) {
|
||||
_cubit.send(v);
|
||||
_input.clear();
|
||||
},
|
||||
suffix: IconButton(
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
onPressed: () {
|
||||
_cubit.send(_input.text);
|
||||
_input.clear();
|
||||
},
|
||||
),
|
||||
),
|
||||
child: state.messages.isEmpty
|
||||
? EmptyView(
|
||||
icon: Icons.forum_outlined,
|
||||
message: l10n.chatEmpty,
|
||||
)
|
||||
: ListView.builder(
|
||||
// الأحدث أسفل، والقائمة مقلوبة كي تبقى عند آخر رسالة.
|
||||
reverse: true,
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final msg = state.messages[state.messages.length - 1 - i];
|
||||
return _Bubble(msg: msg, mine: msg.senderId == myId);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bubble extends StatelessWidget {
|
||||
const _Bubble({required this.msg, required this.mine});
|
||||
|
||||
final ChatMessage msg;
|
||||
final bool mine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment:
|
||||
mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: Space.xxs),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.sm,
|
||||
vertical: Space.xs,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: mine
|
||||
? context.colors.primaryContainer
|
||||
: context.tripzColors.surfaceRaised,
|
||||
borderRadius: Radii.card_,
|
||||
border: mine ? null : Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Text(msg.body, style: context.texts.bodyMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ class ProfileState {
|
||||
|
||||
final ViewStatus status;
|
||||
final bool saving;
|
||||
final AuthFailure? failure;
|
||||
final ApiFailure? failure;
|
||||
final AppUser? user;
|
||||
|
||||
ProfileState copyWith({
|
||||
ViewStatus? status,
|
||||
bool? saving,
|
||||
AuthFailure? failure,
|
||||
ApiFailure? failure,
|
||||
AppUser? user,
|
||||
bool clearFailure = false,
|
||||
}) {
|
||||
@@ -53,7 +53,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
emit(state.copyWith(saving: false, user: user));
|
||||
return user;
|
||||
} on ApiException catch (e) {
|
||||
emit(state.copyWith(saving: false, failure: e.toAuthFailure()));
|
||||
emit(state.copyWith(saving: false, failure: e.toApiFailure()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
|
||||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
if (state.failure != null)
|
||||
AuthFailureText(failure: state.failure!),
|
||||
ApiFailureText(failure: state.failure!),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -109,6 +109,8 @@ class RideCubit extends Cubit<RideState> {
|
||||
|
||||
// ── التخطيط ────────────────────────────────────────────────────────────
|
||||
|
||||
void couponChanged(String code) => emit(state.copyWith(couponCode: code));
|
||||
|
||||
void openPlanner() => emit(state.copyWith(phase: RidePhase.planning));
|
||||
|
||||
void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle));
|
||||
@@ -253,6 +255,9 @@ class RideCubit extends Cubit<RideState> {
|
||||
destination: to,
|
||||
serviceClass: type.code,
|
||||
stops: state.stops,
|
||||
couponCode: state.couponCode.trim().isEmpty
|
||||
? null
|
||||
: state.couponCode.trim(),
|
||||
);
|
||||
if (isClosed) return;
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ class RideState extends Equatable {
|
||||
this.error = RideError.none,
|
||||
this.pickTarget = PickTarget.destination,
|
||||
this.cameraTarget,
|
||||
this.couponCode = '',
|
||||
});
|
||||
|
||||
final RidePhase phase;
|
||||
@@ -92,6 +93,9 @@ class RideState extends Equatable {
|
||||
/// وجهة الكاميرا حين يقودها المنطق لا المستخدم.
|
||||
final GeoPoint? cameraTarget;
|
||||
|
||||
/// كود الخصم — يُقيَّم على الخادم عند الطلب لا في التطبيق.
|
||||
final String couponCode;
|
||||
|
||||
bool get canConfirm =>
|
||||
origin != null &&
|
||||
destination != null &&
|
||||
@@ -117,6 +121,7 @@ class RideState extends Equatable {
|
||||
RideError? error,
|
||||
PickTarget? pickTarget,
|
||||
GeoPoint? cameraTarget,
|
||||
String? couponCode,
|
||||
bool clearDestination = false,
|
||||
bool clearRoute = false,
|
||||
bool clearTrip = false,
|
||||
@@ -143,6 +148,7 @@ class RideState extends Equatable {
|
||||
error: error ?? this.error,
|
||||
pickTarget: pickTarget ?? this.pickTarget,
|
||||
cameraTarget: clearCamera ? null : (cameraTarget ?? this.cameraTarget),
|
||||
couponCode: couponCode ?? this.couponCode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,5 +172,6 @@ class RideState extends Equatable {
|
||||
error,
|
||||
pickTarget,
|
||||
cameraTarget,
|
||||
couponCode,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/design/tokens.dart';
|
||||
|
||||
import '../../../../core/design/tripz_colors.dart';
|
||||
|
||||
import '../../../../core/build_config.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/router.dart';
|
||||
import '../../../../core/tenant/feature_gate.dart';
|
||||
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
|
||||
@@ -41,6 +45,15 @@ class ActiveTripSheet extends StatelessWidget {
|
||||
Text(_statusText(context), style: context.texts.titleMedium),
|
||||
const SizedBox(height: Space.md),
|
||||
DriverBadge(trip: trip),
|
||||
if (Features.chat && context.tenantHas('chat')) ...[
|
||||
const SizedBox(height: Space.sm),
|
||||
TripzButton.secondary(
|
||||
label: l10n.chatTitle,
|
||||
icon: Icons.forum_outlined,
|
||||
expanded: true,
|
||||
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
|
||||
),
|
||||
],
|
||||
if (trip.priceForPassenger != null) ...[
|
||||
const SizedBox(height: Space.md),
|
||||
FareSummary(trip: trip),
|
||||
|
||||
@@ -12,7 +12,10 @@ import '../../../../core/l10n/l10n.dart';
|
||||
|
||||
import '../../../../core/ui/status_banner.dart';
|
||||
|
||||
import '../../../../core/build_config.dart';
|
||||
import '../../../../core/tenant/feature_gate.dart';
|
||||
import '../../../../core/ui/tripz_button.dart';
|
||||
import '../../../../core/ui/tripz_text_field.dart';
|
||||
|
||||
import '../../cubit/ride_cubit.dart';
|
||||
|
||||
@@ -92,6 +95,15 @@ class ConfirmSheet extends StatelessWidget {
|
||||
const SizedBox(height: Space.sm),
|
||||
StatusBanner(message: l10n.rideNoDrivers, tone: BannerTone.warning),
|
||||
],
|
||||
// الكوبون خلف طبقتَي الميزات: مبنيّ في الـbinary ومشترى للمستأجر.
|
||||
if (Features.coupons && context.tenantHas('coupons')) ...[
|
||||
const SizedBox(height: Space.md),
|
||||
TripzTextField(
|
||||
hint: l10n.rideCoupon,
|
||||
prefixIcon: Icons.local_offer_outlined,
|
||||
onChanged: cubit.couponChanged,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Space.md),
|
||||
TripzButton.primary(
|
||||
label: l10n.rideConfirmTrip,
|
||||
|
||||
Reference in New Issue
Block a user