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