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:
Hamza-Ayed
2026-08-05 00:04:28 +03:00
co-authored by Claude Opus 5
parent 096acae74e
commit bb2c0de478
56 changed files with 1936 additions and 68 deletions
@@ -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();
+9 -9
View File
@@ -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;
}
}
+4 -1
View File
@@ -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;
+8
View File
@@ -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()));
+6 -1
View File
@@ -188,5 +188,10 @@
"aboutVersion": "الإصدار",
"notifTitle": "الإشعارات",
"notifEnable": "تفعيل الإشعارات",
"notifDenied": "الإشعارات موقوفة من إعدادات النظام"
"notifDenied": "الإشعارات موقوفة من إعدادات النظام",
"chatTitle": "المحادثة",
"chatHint": "اكتب رسالة",
"chatEmpty": "لا رسائل بعد",
"rideCoupon": "كود خصم",
"rideCouponApply": "تطبيق"
}
+6 -1
View File
@@ -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"
}
+7
View File
@@ -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(),
+10 -10
View File
@@ -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,