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