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
@@ -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),