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
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user