Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-05 14:12:41 +03:00
parent 58928cd258
commit 95e2e4f35d
51 changed files with 3141 additions and 390 deletions
@@ -1,26 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/build_config.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/router.dart';
import '../../../../core/tenant/feature_gate.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../cubit/ride_cubit.dart';
import '../../data/models/route_info.dart';
import '../../data/models/trip.dart';
import 'cancel_sheet.dart';
import 'driver_badge.dart';
import 'fare_summary.dart';
import 'trip_contact_row.dart';
/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد.
class ActiveTripSheet extends StatelessWidget {
const ActiveTripSheet({super.key, required this.trip});
const ActiveTripSheet({super.key, required this.trip, this.pickupRoute});
final Trip trip;
/// مسار اقتراب السائق — منه شريط الوقت والمسافة.
final RouteInfo? pickupRoute;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -29,22 +30,27 @@ class ActiveTripSheet extends StatelessWidget {
TripStatus.driverArriving,
}.contains(trip.status);
// الشريط أثناء الاقتراب وحده: بعد ركوب الراكب لم يعد «كم بقي ليصل» سؤالاً.
final eta = const {
TripStatus.assigned,
TripStatus.driverArriving,
}.contains(trip.status)
? pickupRoute
: null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_statusText(context), style: context.texts.titleMedium),
if (eta != null) ...[
const SizedBox(height: Space.sm),
TripEtaStrip(minutes: eta.durationMin, km: eta.distanceKm),
],
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}'),
),
],
const SizedBox(height: Space.sm),
TripContactRow(trip: trip),
if (trip.priceForPassenger != null) ...[
const SizedBox(height: Space.md),
FareSummary(trip: trip),
@@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/design/typography.dart';
import '../../data/models/trip.dart';
import 'vehicle_color.dart';
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
/// أوضح موضع وأكبر تباين.
@@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (trip.driverName == null) return const SizedBox.shrink();
// اللون **يُرسم لا يُكتب فقط**: الراكب يمسح الشارع بعينه بحثاً عن سيارة
// بيضاء، لا يقرأ كلمة «أبيض». نمط سيرو (`apply_order_widget.dart:333`)
// يلوّن رمز السيارة بلون المركبة الفعلي — وهذا مقابله.
final carColor = VehicleColor.resolve(
hex: trip.vehicleColorHex,
name: trip.vehicleColor,
);
// سطر المركبة: الموديل واللون معاً بفاصل — أحدهما قد يغيب.
final vehicleLine = [
if (trip.vehicleModel != null && trip.vehicleModel!.isNotEmpty)
trip.vehicleModel!,
if (trip.vehicleColor != null && trip.vehicleColor!.isNotEmpty)
trip.vehicleColor!,
].join(' • ');
return Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: context.colors.primaryContainer,
child: Icon(
Icons.person_rounded,
color: context.colors.onPrimaryContainer,
),
),
_CarAvatar(color: carColor),
const SizedBox(width: Space.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(trip.driverName!, style: context.texts.titleSmall),
if (trip.vehicleModel != null)
if (vehicleLine.isNotEmpty)
Text(
trip.vehicleModel!,
vehicleLine,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
@@ -62,6 +73,43 @@ class DriverBadge extends StatelessWidget {
}
}
/// رمز سيارة ملوّن بلون المركبة الحقيقي.
///
/// اللون الفاتح (أبيض/بيج) يختفي على سطح فاتح، فتُرسم حلقة حدّ حوله دائماً —
/// وبها تبقى السيارة البيضاء مقروءة في الوضع النهاري.
class _CarAvatar extends StatelessWidget {
const _CarAvatar({this.color});
final Color? color;
@override
Widget build(BuildContext context) {
final resolved = color ?? context.colors.onSurfaceVariant;
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
// ‏10% من لون السيارة خلفيةً — يربط الرمز بلونه بلا صراخ.
color: color?.withValues(alpha: 0.12) ??
context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Center(
child: Icon(
Icons.directions_car_filled_rounded,
size: Sizes.icon,
color: resolved,
// ظلّ خفيف كي لا تذوب السيارة البيضاء في الخلفية الفاتحة.
shadows: const [
Shadow(color: Color(0x33000000), blurRadius: 2),
],
),
),
);
}
}
/// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية.
class _Plate extends StatelessWidget {
const _Plate({required this.plate});
@@ -0,0 +1,219 @@
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/ui/tripz_text_field.dart';
import '../../cubit/ride_cubit.dart';
import '../../cubit/ride_state.dart';
import '../../data/models/place.dart';
import 'route_timeline.dart';
/// بحث الأماكن بملء الشاشة — نمط سيرو
/// (`apps/rider/lib/views/home/route_planner/rp_search_dialog.dart`):
/// ديالوج منفصل عن الورقة السفلية بدل حقل مضغوط داخلها (docs/40 §2).
///
/// [target] يحدّد الحقل الذي يُملأ عند الاختيار — البحث نفسه (`searchPlaces`)
/// مشترك، فلا حاجة لنسخة ثانية من الكيوبت لكل اتجاه.
class PlaceSearchDialog extends StatefulWidget {
const PlaceSearchDialog({super.key, required this.target});
final PickTarget target;
static Future<void> show(BuildContext context, {required PickTarget target}) {
final cubit = context.read<RideCubit>();
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: Motion.page,
pageBuilder: (context, animation, secondaryAnimation) =>
BlocProvider.value(
value: cubit,
child: PlaceSearchDialog(target: target),
),
transitionBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
);
},
);
}
@override
State<PlaceSearchDialog> createState() => _PlaceSearchDialogState();
}
class _PlaceSearchDialogState extends State<PlaceSearchDialog> {
final _query = TextEditingController();
@override
void dispose() {
// البحث ينتهي مع الديالوج — لا يبقى مصدر نتائج قديمة معلَّقاً في الحالة
// حين يُفتح البحث ثانيةً بحقل فارغ.
context.read<RideCubit>().searchPlaces('');
_query.dispose();
super.dispose();
}
IconData _iconFor(String category) => switch (category) {
'street' || 'road' => Icons.route_outlined,
'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined,
'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined,
'school' || 'university' => Icons.school_outlined,
'mall' || 'shop' || 'store' => Icons.storefront_outlined,
'mosque' => Icons.mosque_outlined,
'hotel' => Icons.hotel_outlined,
_ => Icons.place_outlined,
};
void _select(BuildContext context, Place place) {
Navigator.of(context).pop();
context.read<RideCubit>().pickPlace(place, widget.target);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final hint = widget.target == PickTarget.origin
? l10n.rideOrigin
: l10n.rideSearchHint;
return Scaffold(
backgroundColor: context.colors.surface,
body: SafeArea(
child: Padding(
padding: Space.page,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(l10n.rideWhereTo, style: context.texts.titleMedium),
),
],
),
const SizedBox(height: Space.sm),
BlocSelector<RideCubit, RideState, ({String o, String d, int stops})>(
selector: (s) => (o: s.originLabel, d: s.destinationLabel, stops: s.stops.length),
builder: (context, r) => RouteTimeline(
originLabel: r.o,
destinationLabel: r.d,
stopCount: r.stops,
onTapOrigin: () {
if (widget.target != PickTarget.origin) {
Navigator.of(context).pop();
PlaceSearchDialog.show(context, target: PickTarget.origin);
}
},
onTapDestination: () {
if (widget.target != PickTarget.destination) {
Navigator.of(context).pop();
PlaceSearchDialog.show(context, target: PickTarget.destination);
}
},
),
),
const SizedBox(height: Space.md),
TripzTextField(
controller: _query,
hint: hint,
prefixIcon: Icons.search_rounded,
autofocus: true,
onChanged: context.read<RideCubit>().searchPlaces,
suffix: _query.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_query.clear();
context.read<RideCubit>().searchPlaces('');
setState(() {});
},
),
),
const SizedBox(height: Space.sm),
Expanded(
child: BlocBuilder<RideCubit, RideState>(
builder: (context, state) {
if (state.searching) {
return const Padding(
padding: EdgeInsets.only(top: Space.md),
child: LinearProgressIndicator(minHeight: 2),
);
}
if (_query.text.trim().length >= 2 &&
state.searchResults.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.only(top: Space.lg),
child: Text(
l10n.rideSearchNoResults,
textAlign: TextAlign.center,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
);
}
// حقل فارغ ⇒ الأخيرة بدل شاشة بيضاء — الراكب غالباً
// ذاهبٌ لمكان سبق أن ذهب إليه.
final results = state.searchResults.isEmpty &&
_query.text.trim().isEmpty
? context.read<RideCubit>().recentPlaces
: state.searchResults;
return ListView.separated(
padding: const EdgeInsets.only(top: Space.xs),
itemCount: results.length,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) {
final place = results[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 18,
backgroundColor: context.colors.surfaceContainerHighest,
child: Icon(
_iconFor(place.category),
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
),
title: Text(place.label, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: place.address.isEmpty
? null
: Text(place.address, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => _select(context, place),
);
},
);
},
),
),
TextButton.icon(
icon: const Icon(Icons.map_outlined),
label: Text(l10n.ridePickOnMap),
onPressed: () {
Navigator.of(context).pop();
context.read<RideCubit>().startMapPick(widget.target);
},
),
],
),
),
),
);
}
}
@@ -2,52 +2,25 @@ 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/ui/tripz_button.dart';
import '../../../../core/ui/tripz_text_field.dart';
import '../../cubit/ride_cubit.dart';
import '../../cubit/ride_state.dart';
import 'place_search_dialog.dart';
import 'route_timeline.dart';
/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3):
/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة.
class PlannerSheet extends StatefulWidget {
/// المخطّط الموسّع: الخط الزمني للمسار فقط — البحث بحدّ ذاته انتقل إلى
/// [PlaceSearchDialog] بملء الشاشة (docs/40 §2). الضغط على أي حقل في الخط
/// الزمني يفتح ذلك الديالوج بدل بحث مضغوط داخل هذه الورقة.
class PlannerSheet extends StatelessWidget {
const PlannerSheet({super.key, required this.state});
final RideState state;
@override
State<PlannerSheet> createState() => _PlannerSheetState();
}
class _PlannerSheetState extends State<PlannerSheet> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
/// أيقونة تعبّر عن نوع المكان — مطعم غير شارع غير مستشفى. يقرأها المستخدم
/// أسرع من النصّ.
IconData _iconFor(String category) => switch (category) {
'street' || 'road' => Icons.route_outlined,
'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined,
'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined,
'school' || 'university' => Icons.school_outlined,
'mall' || 'shop' || 'store' => Icons.storefront_outlined,
'mosque' => Icons.mosque_outlined,
'hotel' => Icons.hotel_outlined,
_ => Icons.place_outlined,
};
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final cubit = context.read<RideCubit>();
final state = widget.state;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -57,81 +30,12 @@ class _PlannerSheetState extends State<PlannerSheet> {
originLabel: state.originLabel,
destinationLabel: state.destinationLabel,
stopCount: state.stops.length,
onTapOrigin: () => cubit.startMapPick(PickTarget.origin),
onTapDestination: () => cubit.startMapPick(PickTarget.destination),
onTapOrigin: () =>
PlaceSearchDialog.show(context, target: PickTarget.origin),
onTapDestination: () =>
PlaceSearchDialog.show(context, target: PickTarget.destination),
),
const SizedBox(height: Space.md),
TripzTextField(
controller: _query,
hint: l10n.rideSearchHint,
prefixIcon: Icons.search_rounded,
// الكيبورد يفتح فوراً: المستخدم فتح المخطّط ليكتب، لا لينظر.
autofocus: true,
onChanged: cubit.searchPlaces,
suffix: _query.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_query.clear();
cubit.searchPlaces('');
setState(() {});
},
),
),
if (state.searching)
const Padding(
padding: EdgeInsets.only(top: Space.md),
child: LinearProgressIndicator(minHeight: 2),
),
if (!state.searching &&
_query.text.trim().length >= 2 &&
state.searchResults.isEmpty)
Padding(
padding: const EdgeInsets.only(top: Space.md),
child: Text(
l10n.rideSearchNoResults,
textAlign: TextAlign.center,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
if (state.searchResults.isNotEmpty)
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.only(top: Space.xs),
itemCount: state.searchResults.length,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) {
final place = state.searchResults[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 18,
backgroundColor: context.colors.surfaceContainerHighest,
child: Icon(
_iconFor(place.category),
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
),
title: Text(place.label, maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: place.address.isEmpty
? null
: Text(place.address,
maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () {
_query.clear();
cubit.pickPlace(place, PickTarget.destination);
},
);
},
),
),
const SizedBox(height: Space.md),
TripzButton.secondary(
label: l10n.ridePickOnMap,
icon: Icons.map_outlined,
@@ -1,3 +1,5 @@
import 'dart:developer' as dev;
import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im;
@@ -35,6 +37,7 @@ class RideMap extends StatefulWidget {
this.destination,
this.driver,
this.route,
this.pickupRoute,
this.cameraTarget,
this.onCameraIdle,
this.bottomInset = 280,
@@ -46,6 +49,10 @@ class RideMap extends StatefulWidget {
final GeoPoint? destination;
final GeoPoint? driver;
final RouteInfo? route;
/// مسار اقتراب السائق — يُرسم بلون وعرض مختلفين عن [route].
final RouteInfo? pickupRoute;
final GeoPoint? cameraTarget;
/// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت.
@@ -83,6 +90,29 @@ class _RideMapState extends State<RideMap> {
static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
/// حارس الإحداثيات — **بلا هذا يسقط التطبيق أصلاً لا استثناءً في دارت**.
///
/// `std::domain_error` الذي رأيناه (2026-08-05) يأتي من `mbgl::LatLng`
/// في MapLibre الأصلي، وهو يرمي حرفياً عند: `latitude must not be NaN` ·
/// `longitude must not be NaN` · `latitude must be between -90 and 90` ·
/// `longitude must not be infinite`. (السلاسل الأربع موجودة في ثنائي
/// `MapLibre.framework`، وهي مصدر `std::domain_error` الوحيد فيه — أمّا
/// الستايل فبريء: الملفّات الخمسة كلها اجتازت `validateStyleMin` بصفر
/// أخطاء.)
///
/// وهو استثناء **C++ يعبر حدّ المنصّة**، فلا `try/catch` في دارت يمسكه:
/// المنع قبل التمرير هو العلاج الوحيد.
static im.LatLng? _safe(GeoPoint? p, String tag) {
if (p == null) return null;
final ok = p.lat.isFinite && p.lng.isFinite && p.lat.abs() <= 90;
if (!ok) {
dev.log('إحداثية غير صالحة رُفضت [$tag]: ${p.lat}, ${p.lng}',
name: 'RideMap');
return null;
}
return im.LatLng(p.lat, p.lng);
}
/// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني.
static const _lightStyleAsset = 'assets/style.json';
static const _darkStyleAsset = 'assets/style_dark.json';
@@ -123,9 +153,66 @@ class _RideMapState extends State<RideMap> {
}
void _moveTo(GeoPoint p) {
_controller?.animateCamera(
im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
);
final target = _safe(p, 'moveTo');
if (target == null) return;
_controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5));
}
/// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة)
/// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً.
static List<im.LatLng> _decodeRoute(String encoded) {
final all = im.PolylineUtils.decode(encoded);
final good = all
.where((p) =>
p.latitude.isFinite &&
p.longitude.isFinite &&
p.latitude.abs() <= 90)
.toList();
if (good.length != all.length) {
dev.log('نقاط مسار غير صالحة رُفضت: ${all.length - good.length}',
name: 'RideMap');
}
return good;
}
/// المسارات كطبقات `Polyline` — مجموعة فارغة ما لم يكن الستايل جاهزاً
/// والنقاط صالحة.
///
/// **خطّان لا خطّ**: مسار الرحلة (`route`) بلون المسار وعرض 5، ومسار
/// اقتراب السائق (`pickupRoute`) أرفع وبلون المعلومات — كي يفرّق الراكب
/// بنظرة بين «طريقي» و«طريق السائق إليّ».
Set<im.Polyline> _routePolylines(Color routeColor, Color pickupColor) {
if (!_styleReady) return const {};
final lines = <im.Polyline>{};
final encoded = widget.route?.encodedPoints;
if (encoded != null && encoded.isNotEmpty) {
final points = _decodeRoute(encoded);
if (points.length >= 2) {
lines.add(im.Polyline(
polylineId: const im.PolylineId('route'),
points: points,
color: routeColor,
width: 5,
));
}
}
final pickup = widget.pickupRoute?.encodedPoints;
if (pickup != null && pickup.isNotEmpty) {
final points = _decodeRoute(pickup);
if (points.length >= 2) {
lines.add(im.Polyline(
polylineId: const im.PolylineId('pickup'),
points: points,
color: pickupColor,
width: 4,
));
}
}
return lines;
}
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
@@ -135,7 +222,7 @@ class _RideMapState extends State<RideMap> {
void _fitRoute() {
final encoded = widget.route?.encodedPoints;
if (encoded == null || encoded.isEmpty) return;
final points = im.PolylineUtils.decode(encoded);
final points = _decodeRoute(encoded);
if (points.length < 2) return;
var minLat = points.first.latitude, maxLat = points.first.latitude;
@@ -147,6 +234,10 @@ class _RideMapState extends State<RideMap> {
if (p.longitude > maxLng) maxLng = p.longitude;
}
// `mbgl::EdgeInsets` يرمي هو الآخر على NaN (`bottom must not be NaN`)،
// و`bottomInset` مشتقّ من `MediaQuery` فقد يصل صفراً أو NaN في أوّل إطار.
final bottom = widget.bottomInset.isFinite ? widget.bottomInset : 280.0;
_controller?.animateCamera(
im.CameraUpdate.newLatLngBounds(
im.LatLngBounds(
@@ -156,7 +247,7 @@ class _RideMapState extends State<RideMap> {
left: 48,
right: 48,
top: 120,
bottom: widget.bottomInset,
bottom: bottom,
),
);
}
@@ -167,19 +258,19 @@ class _RideMapState extends State<RideMap> {
if (controller == null || _syncing) return;
_syncing = true;
Future<Offset?> px(GeoPoint? p) async {
if (p == null) return null;
final point = await controller.getScreenCoordinate(
im.LatLng(p.lat, p.lng),
);
// `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس.
Future<Offset?> px(GeoPoint? p, String tag) async {
final target = _safe(p, tag);
if (target == null) return null;
final point = await controller.getScreenCoordinate(target);
return Offset(point.x.toDouble(), point.y.toDouble());
}
try {
final results = await Future.wait([
px(widget.origin),
px(widget.destination),
px(widget.driver),
px(widget.origin, 'origin'),
px(widget.destination, 'destination'),
px(widget.driver, 'driver'),
]);
if (!mounted) return;
setState(() {
@@ -205,22 +296,19 @@ class _RideMapState extends State<RideMap> {
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
// الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم.
//
// ⚠️ **ستايل محلّي لا بعيد.** ستايل map-saas البعيد
// (`/api/maps/style.json?theme=light`) فيه **12 طبقة `symbol` تستعمل
// `icon-image` بلا `sprite` معرَّف** — وMapLibre الأصلي يرمي عندها
// `std::domain_error` فيُسقط التطبيق قبل أن يظهر إطار واحد
// (مثبت 2026-08-05). الستايلان المرفقان في `assets/` سليمان: بهما
// `sprite` وبلاطاتهما من `tiles.intaleqapp.com` نفسها.
//
// وهذا حرفياً حلّ سيرو الميداني: انظر
// **ستايل محلّي لا بعيد** — كما في سيرو الميداني حرفياً:
// `apps/rider/lib/views/home/map_widget.dart/google_map_passenger_widget.dart`
// — يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// مطابقان لهذين طبقةً بطبقة (لا يختلفان إلا في اسم العلامة التجارية).
// فلا تُعِد هذا السطر إلى `IntaleqStyles.light/obsidian` — جُرّب وأسقط
// التطبيق مرتين.
//
// ومعها نكسب الإقلاع بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول
// إقلاع» (docs/26 §1).
//
// ملاحظة تصحيحية (2026-08-05): كان مكتوباً هنا أن ستايل map-saas البعيد
// هو سبب `std::domain_error` لأنه بلا `sprite`. **هذا خطأ.** الستايلات
// الخمسة (المحلّيان + البعيد + ستايلا الحزمة) اجتازت
// `validateStyleMin` من `@maplibre/maplibre-gl-style-spec` بصفر أخطاء،
// والسقوط تكرّر بالستايل المحلّي أيضاً. المصدر الحقيقي هو حارس
// الإحداثيات في `mbgl::LatLng`/`mbgl::EdgeInsets` — انظر `_safe` أعلاه.
final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset;
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
_styleUrl = styleUrl;
@@ -234,13 +322,17 @@ class _RideMapState extends State<RideMap> {
// فنقفله من هنا بدل الاتّكال على ذلك.
autoCache: false,
initialCameraPosition: im.CameraPosition(
target: widget.origin == null
? _fallback
: im.LatLng(widget.origin!.lat, widget.origin!.lng),
target: _safe(widget.origin, 'initialCamera') ?? _fallback,
zoom: 15,
),
styleUrl: styleUrl,
myLocationEnabled: true,
// نقطة «موقعي» تُفعَّل **بعد** أن يصلنا موقع حقيقي لا عند الإنشاء.
// قبل ذلك يكون `MLNMapView.userLocation.coordinate` هو
// `kCLLocationCoordinate2DInvalid` أي (-180, -180) — وخط عرض -180
// يخترق حارس `mbgl::LatLng` نفسه (`latitude must be between -90
// and 90`). و`origin` لا يصير غير فارغ إلا بعد منح الإذن ووصول
// قراءة فعلية، فهو الشرط الصحيح تماماً.
myLocationEnabled: widget.origin != null,
compassEnabled: false,
zoomControlsEnabled: false,
onMapCreated: _onCreated,
@@ -257,20 +349,9 @@ class _RideMapState extends State<RideMap> {
}
_syncPixels();
},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
polylines: _styleReady && widget.route != null &&
widget.route!.encodedPoints.isNotEmpty
? {
im.Polyline(
polylineId: const im.PolylineId('route'),
points: im.PolylineUtils.decode(
widget.route!.encodedPoints,
),
color: colors.mapRoute,
width: 5,
),
}
: const {},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل
// وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً).
polylines: _routePolylines(colors.mapRoute, colors.info),
),
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../core/build_config.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/router.dart';
import '../../../../core/tenant/feature_gate.dart';
import '../../data/models/trip.dart';
/// طرق التواصل مع السائق في صفّ واحد — نمط سيرو
/// (`controller/functions/launch.dart`: هاتف · رسالة · واتساب).
///
/// **ثلاثة أفعال لا زرّ واحد**: الشبكة تسقط فتفشل المحادثة داخل التطبيق،
/// وحينها يبقى الاتصال الهاتفي العادي — وهو الوحيد الذي يعمل بلا إنترنت.
/// لذلك لا يُختزل شيء منها في قائمة مخفيّة.
///
/// **المكالمة المجانية (VoIP) غير موجودة هنا** عن قصد: `Features.calls`
/// مطفأ لأن WebRTC لم يُبنَ في التطبيق بعد (الخادم يدعمه — `call:*` في
/// docs/38 §9). حين يُبنى يُضاف زرّ رابع خلف نفس العَلَم.
class TripContactRow extends StatelessWidget {
const TripContactRow({super.key, required this.trip});
final Trip trip;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final phone = trip.driverPhone;
final hasPhone = phone != null && phone.trim().isNotEmpty;
return Row(
children: [
if (Features.chat && context.tenantHas('chat'))
Expanded(
child: _ContactAction(
icon: Icons.forum_outlined,
label: l10n.chatTitle,
onTap: () => context.push('${Routes.chat}?trip=${trip.id}'),
),
),
if (hasPhone) ...[
const SizedBox(width: Space.xs),
Expanded(
child: _ContactAction(
icon: Icons.call_outlined,
label: l10n.tripCall,
onTap: () => _launch('tel:${_clean(phone)}'),
),
),
const SizedBox(width: Space.xs),
Expanded(
child: _ContactAction(
icon: Icons.sms_outlined,
label: l10n.tripMessage,
onTap: () => _launch('sms:${_clean(phone)}'),
),
),
],
],
);
}
/// الأرقام تصل بصيغ مختلفة (`0790…`, `+962790…`, بمسافات) — يُنظَّف الرقم
/// من كل ما ليس رقماً أو `+`، وإلا رفضه `tel:` صامتاً.
static String _clean(String raw) {
final kept = raw.replaceAll(RegExp(r'[^0-9+]'), '');
return kept.isEmpty ? raw : kept;
}
static Future<void> _launch(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
// جهازٌ بلا تطبيق هاتف (جهاز لوحي) يفشل هنا — لا يُسقط الشاشة.
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _ContactAction extends StatelessWidget {
const _ContactAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.card_,
child: InkWell(
onTap: onTap,
borderRadius: Radii.card_,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Space.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Sizes.iconSm, color: context.colors.primary),
const SizedBox(height: Space.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.texts.labelSmall,
),
],
),
),
),
);
}
}
/// شريط «كم بقي» — الوقت والمسافة من مسار الاقتراب الحيّ.
///
/// هذا **أكثر ما يسأل عنه الراكب** بعد وصول السائق للشاشة، وكان غائباً
/// تماماً: البطاقة كانت تقول «السائق في الطريق» بلا رقم واحد.
class TripEtaStrip extends StatelessWidget {
const TripEtaStrip({
super.key,
required this.minutes,
required this.km,
});
final double minutes;
final double km;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
),
decoration: BoxDecoration(
color: context.tripzColors.info.withValues(alpha: 0.10),
borderRadius: Radii.card_,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.access_time_rounded,
size: Sizes.iconSm,
color: context.tripzColors.info,
),
const SizedBox(width: Space.xs),
Text(
// أقلّ من دقيقة يُعرض «1» لا «0» — «0 دقيقة» تعني «وصل» وهي كذبة.
l10n.rideEta(minutes < 1 ? '1' : minutes.round().toString()),
style: context.texts.titleSmall,
),
const SizedBox(width: Space.sm),
Text('·', style: context.texts.titleSmall),
const SizedBox(width: Space.sm),
Text(
l10n.rideDistanceKm(km.toStringAsFixed(1)),
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// يحوّل لون المركبة — hex أو اسماً — إلى لون يُرسم به.
///
/// الخادم غير متّسق عمداً: `vehicles` عندها `color_hex` بينما حدث `assigned`
/// يرسل `color` اسماً (`trips.service.ts:427`). فالمُحوِّل يقبل الاثنين
/// ويُقدّم الـhex لأنه دقيق.
///
/// الأسماء تُطابَق **بالعربية والإنجليزية معاً**: السائقون يُدخلونها بلغتهم،
/// وقائمة إنجليزية وحدها تعني رمادياً افتراضياً لنصف الأسطول.
class VehicleColor {
const VehicleColor._();
/// اللون الذي يُرسم به رمز السيارة، أو `null` إن تعذّرت المطابقة —
/// و`null` تعني «لا تلوّن» لا «لوّن رمادياً»، فالرمادي لونُ سيارةٍ حقيقي
/// ولا يصحّ أن يعني «مجهول».
static Color? resolve({String? hex, String? name}) =>
_fromHex(hex) ?? _fromName(name);
static Color? _fromHex(String? hex) {
if (hex == null) return null;
var raw = hex.replaceFirst('#', '').trim();
if (raw.length == 3) {
// ‏`#f00` → `#ff0000`: صيغة مختصرة شائعة في لوحات الإدارة.
raw = raw.split('').map((c) => '$c$c').join();
}
if (raw.length == 6) raw = 'FF$raw';
if (raw.length != 8) return null;
final value = int.tryParse(raw, radix: 16);
return value == null ? null : Color(value);
}
static const _names = <String, Color>{
'white': Colors.white,
'أبيض': Colors.white,
'black': Colors.black,
'أسود': Colors.black,
'silver': Color(0xFFC0C0C0),
'فضي': Color(0xFFC0C0C0),
'grey': Color(0xFF808080),
'gray': Color(0xFF808080),
'رمادي': Color(0xFF808080),
'red': Color(0xFFD32F2F),
'أحمر': Color(0xFFD32F2F),
'blue': Color(0xFF1976D2),
'أزرق': Color(0xFF1976D2),
'green': Color(0xFF388E3C),
'أخضر': Color(0xFF388E3C),
'yellow': Color(0xFFFBC02D),
'أصفر': Color(0xFFFBC02D),
'orange': Color(0xFFF57C00),
'برتقالي': Color(0xFFF57C00),
'brown': Color(0xFF6D4C41),
'بني': Color(0xFF6D4C41),
'beige': Color(0xFFE8DCC4),
'بيج': Color(0xFFE8DCC4),
'gold': Color(0xFFC9A227),
'ذهبي': Color(0xFFC9A227),
'navy': Color(0xFF1A2340),
'كحلي': Color(0xFF1A2340),
};
static Color? _fromName(String? name) {
if (name == null) return null;
return _names[name.trim().toLowerCase()];
}
}