Update codebase
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/design/tripz_colors.dart';
|
||||
@@ -55,6 +56,16 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// بطاقة رحلة في السجلّ.
|
||||
///
|
||||
/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة
|
||||
/// دفع، رغم أن الخادم يرسلها كلها في `GET /trips/mine`
|
||||
/// (`trip.entity.ts`: `requested_at`, `distance_km`, `duration_min`,
|
||||
/// `payment_method`, `service_class`). البطاقة الآن تقرأ ما كان يصل ويُهمَل.
|
||||
///
|
||||
/// **العناوين النصّية غائبة عمداً**: `trip.entity.ts` يخزّن إحداثيات
|
||||
/// (`origin_lat/lng`) بلا `origin_address`، فسيرو يعرض عناوين ونحن لا نقدر
|
||||
/// حتى يُضاف الحقل على الخادم. عرضُ إحداثيات خام للراكب أسوأ من لا شيء.
|
||||
class _TripCard extends StatelessWidget {
|
||||
const _TripCard({required this.trip});
|
||||
|
||||
@@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget {
|
||||
_ => (l10n.historyStatusLive, colors.info),
|
||||
};
|
||||
|
||||
// سطر التفاصيل: ما توفّر منه فقط، بفواصل — رحلة ملغاة بلا مسافة لا
|
||||
// تعرض «0 كم».
|
||||
final details = [
|
||||
if (trip.distanceKm != null && trip.distanceKm! > 0)
|
||||
l10n.rideDistanceKm(trip.distanceKm!.toStringAsFixed(1)),
|
||||
if (trip.durationMin != null && trip.durationMin! > 0)
|
||||
l10n.rideEta(trip.durationMin!.round().toString()),
|
||||
].join(' · ');
|
||||
|
||||
return TripzCard(
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: Space.sm),
|
||||
Expanded(
|
||||
child: Text(label, style: context.texts.bodyMedium),
|
||||
),
|
||||
Text(
|
||||
Money.format(trip.priceForPassenger ?? trip.quotedFare,
|
||||
trip.currency),
|
||||
style: numericStyle(context.texts.titleSmall),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: Space.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: context.texts.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Money.format(
|
||||
trip.priceForPassenger ?? trip.quotedFare,
|
||||
trip.currency,
|
||||
),
|
||||
style: numericStyle(context.texts.titleSmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trip.requestedAt != null) ...[
|
||||
const SizedBox(height: Space.xs),
|
||||
Text(
|
||||
DateFormat.yMMMd().add_jm().format(trip.requestedAt!.toLocal()),
|
||||
style: context.texts.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (details.isNotEmpty || trip.paymentMethod != null) ...[
|
||||
const SizedBox(height: Space.xs),
|
||||
Row(
|
||||
children: [
|
||||
if (details.isNotEmpty)
|
||||
Expanded(
|
||||
child: Text(
|
||||
details,
|
||||
style: numericStyle(context.texts.bodySmall).copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
if (trip.paymentMethod != null)
|
||||
_Tag(text: _paymentLabel(context, trip.paymentMethod!)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// طرق الدفع المعروفة تُترجم؛ وأي قيمة جديدة من الخادم تُعرض كما هي بدل
|
||||
/// أن تختفي.
|
||||
String _paymentLabel(BuildContext context, String method) {
|
||||
final l10n = context.l10n;
|
||||
return switch (method) {
|
||||
'cash' => l10n.paymentCash,
|
||||
'wallet' => l10n.paymentWallet,
|
||||
_ => method,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// شارة صغيرة — طريقة الدفع في السجلّ.
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.xs,
|
||||
vertical: Space.xxs / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: Radii.field_,
|
||||
),
|
||||
child: Text(text, style: context.texts.labelSmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import '../../../core/ui/status_banner.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../cubit/ride_cubit.dart';
|
||||
import '../cubit/ride_state.dart';
|
||||
import '../cubit/trip_simulator.dart';
|
||||
import '../data/models/geo_point.dart';
|
||||
import '../data/models/route_info.dart';
|
||||
import 'widgets/active_trip_sheet.dart';
|
||||
import 'widgets/center_pin.dart';
|
||||
import 'widgets/confirm_sheet.dart';
|
||||
import 'widgets/place_search_dialog.dart';
|
||||
import 'widgets/planner_sheet.dart';
|
||||
import 'widgets/ride_map.dart';
|
||||
import 'widgets/searching_sheet.dart';
|
||||
@@ -30,8 +33,16 @@ class RidePage extends StatefulWidget {
|
||||
State<RidePage> createState() => _RidePageState();
|
||||
}
|
||||
|
||||
/// نصيب الورقة السفلية من ارتفاع الشاشة — الخريطة تأخذ ما تبقّى (~67%).
|
||||
///
|
||||
/// قرار المالك 2026-08-05: الخريطة تُرفع عن القاع بالثلث كي تبقى مساحة
|
||||
/// الورقة خالصة لها. سيرو يرفعها بـ20%، وتريبز بـ33% لأن ورقته تعرض
|
||||
/// خطوطاً أكثر (الخط الزمني + الأنواع + السعر).
|
||||
const double _sheetFraction = 0.33;
|
||||
|
||||
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
||||
late final RideCubit _cubit = sl<RideCubit>()..init();
|
||||
late final TripSimulator _simulator = TripSimulator(_cubit);
|
||||
GeoPoint? _mapCenter;
|
||||
|
||||
@override
|
||||
@@ -43,6 +54,7 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_simulator.stop();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -65,47 +77,82 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
||||
body: Stack(
|
||||
children: [
|
||||
// ── الخريطة ────────────────────────────────────────────────
|
||||
BlocSelector<RideCubit, RideState, _MapData>(
|
||||
selector: (s) => _MapData(
|
||||
origin: s.origin,
|
||||
destination: s.destination,
|
||||
driver: s.driverLocation,
|
||||
route: s.route,
|
||||
camera: s.cameraTarget,
|
||||
originLabel: s.originLabel,
|
||||
destinationLabel: s.destinationLabel,
|
||||
),
|
||||
builder: (context, data) => RideMap(
|
||||
origin: data.origin,
|
||||
destination: data.destination,
|
||||
driver: data.driver,
|
||||
route: data.route,
|
||||
cameraTarget: data.camera,
|
||||
onCameraIdle: (p) => _mapCenter = p,
|
||||
// الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا
|
||||
// يختفي نصفه تحتها.
|
||||
bottomInset: MediaQuery.sizeOf(context).height * 0.34,
|
||||
originLabel: data.originLabel,
|
||||
destinationLabel: data.destinationLabel,
|
||||
),
|
||||
),
|
||||
//
|
||||
// **مرفوعة عن القاع بمقدار [_sheetFraction]** لا مالئةً الشاشة:
|
||||
// الثلث السفلي مساحة الورقة وحدها، فلا تغطّي الورقةُ الخريطةَ
|
||||
// (نمط سيرو — `Positioned(bottom: Get.height * .2, top: 0)` في
|
||||
// `google_map_passenger_widget.dart`).
|
||||
//
|
||||
// والدبّوس **داخل نفس المنطقة** لا في الـ`Stack` الخارجي: مركز
|
||||
// الكاميرا (`onCameraIdle`) هو مركز ودجت الخريطة، فلو بقي
|
||||
// الدبّوس متمركزاً على الشاشة لأشار إلى نقطة غير التي تُؤكَّد.
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.sizeOf(context).height * _sheetFraction,
|
||||
child: Stack(
|
||||
children: [
|
||||
BlocSelector<RideCubit, RideState, _MapData>(
|
||||
selector: (s) => _MapData(
|
||||
origin: s.origin,
|
||||
destination: s.destination,
|
||||
driver: s.driverLocation,
|
||||
route: s.route,
|
||||
pickupRoute: s.pickupRoute,
|
||||
camera: s.cameraTarget,
|
||||
originLabel: s.originLabel,
|
||||
destinationLabel: s.destinationLabel,
|
||||
),
|
||||
builder: (context, data) => RideMap(
|
||||
origin: data.origin,
|
||||
destination: data.destination,
|
||||
driver: data.driver,
|
||||
route: data.route,
|
||||
pickupRoute: data.pickupRoute,
|
||||
cameraTarget: data.camera,
|
||||
onCameraIdle: (p) => _mapCenter = p,
|
||||
// الورقة لم تعد تغطّي الخريطة — تبقى حشوة تنفّس فقط
|
||||
// كي لا يلتصق المسار بحافّة الخريطة السفلية.
|
||||
bottomInset: Space.xxl,
|
||||
originLabel: data.originLabel,
|
||||
destinationLabel: data.destinationLabel,
|
||||
),
|
||||
),
|
||||
|
||||
// ── دبّوس الاختيار ─────────────────────────────────────────
|
||||
BlocSelector<RideCubit, RideState, bool>(
|
||||
selector: (s) => s.phase == RidePhase.pickingOnMap,
|
||||
builder: (context, picking) =>
|
||||
picking ? const CenterPin() : const SizedBox.shrink(),
|
||||
// ── دبّوس الاختيار ─────────────────────────────────────
|
||||
BlocSelector<RideCubit, RideState, bool>(
|
||||
selector: (s) => s.phase == RidePhase.pickingOnMap,
|
||||
builder: (context, picking) =>
|
||||
picking ? const CenterPin() : const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة.
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: Space.page,
|
||||
child: Builder(
|
||||
builder: (context) => _MapFab(
|
||||
icon: Icons.menu_rounded,
|
||||
onTap: Scaffold.of(context).openDrawer,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) => _MapFab(
|
||||
icon: Icons.menu_rounded,
|
||||
onTap: Scaffold.of(context).openDrawer,
|
||||
),
|
||||
),
|
||||
// زرّ المحاكاة — يظهر فقط مع
|
||||
// `--dart-define=SIMULATE_TRIP=true`. الشرط `const` فيُحذف
|
||||
// هو وما تحته من بناء الإنتاج.
|
||||
if (RideCubit.simulationEnabled) ...[
|
||||
const SizedBox(width: Space.xs),
|
||||
_MapFab(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
onTap: () => _simulator.start(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -137,6 +184,7 @@ class _MapData {
|
||||
this.destination,
|
||||
this.driver,
|
||||
this.route,
|
||||
this.pickupRoute,
|
||||
this.camera,
|
||||
this.originLabel = '',
|
||||
this.destinationLabel = '',
|
||||
@@ -145,7 +193,8 @@ class _MapData {
|
||||
final GeoPoint? origin;
|
||||
final GeoPoint? destination;
|
||||
final GeoPoint? driver;
|
||||
final dynamic route;
|
||||
final RouteInfo? route;
|
||||
final RouteInfo? pickupRoute;
|
||||
final GeoPoint? camera;
|
||||
final String originLabel;
|
||||
final String destinationLabel;
|
||||
@@ -157,6 +206,7 @@ class _MapData {
|
||||
other.destination == destination &&
|
||||
other.driver == driver &&
|
||||
other.route == route &&
|
||||
other.pickupRoute == pickupRoute &&
|
||||
other.camera == camera &&
|
||||
other.originLabel == originLabel &&
|
||||
other.destinationLabel == destinationLabel;
|
||||
@@ -167,6 +217,7 @@ class _MapData {
|
||||
destination,
|
||||
driver,
|
||||
route,
|
||||
pickupRoute,
|
||||
camera,
|
||||
originLabel,
|
||||
destinationLabel,
|
||||
@@ -190,11 +241,20 @@ class _Sheet extends StatelessWidget {
|
||||
RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick),
|
||||
RidePhase.confirming => ConfirmSheet(state: state),
|
||||
RidePhase.searching => const SearchingSheet(),
|
||||
RidePhase.active => ActiveTripSheet(trip: state.trip!),
|
||||
RidePhase.active => ActiveTripSheet(
|
||||
trip: state.trip!,
|
||||
pickupRoute: state.pickupRoute,
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
// الحدّ الأدنى = المساحة المحجوزة للورقة تحت الخريطة، وإلا ظهر فراغُ
|
||||
// الـ`Scaffold` بينهما في المراحل قصيرة المحتوى (`idle` مثلاً).
|
||||
// وهو حدّ أدنى لا ثابت: `active` و`confirming` يكبران فوقه بحرّية.
|
||||
constraints: BoxConstraints(
|
||||
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: Radii.sheetTop,
|
||||
@@ -299,21 +359,32 @@ class _Collapsed extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: Space.sm),
|
||||
],
|
||||
// حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً.
|
||||
// حقل بحث لا زرّ: الضغط عليه يفتح بحث الأماكن بملء الشاشة مباشرةً
|
||||
// (docs/40 §2) — لا ورقةً وسيطة ثم بحثاً داخلها.
|
||||
//
|
||||
// الحشوة سخيّة عمداً: الثلث السفلي صار مساحة الورقة وحدها بعد رفع
|
||||
// الخريطة، وحقلٌ نحيف وسط فراغ يبدو مقصوصاً لا أنيقاً.
|
||||
Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: Radii.card_,
|
||||
child: InkWell(
|
||||
onTap: cubit.openPlanner,
|
||||
onTap: () => PlaceSearchDialog.show(
|
||||
context,
|
||||
target: PickTarget.destination,
|
||||
),
|
||||
borderRadius: Radii.card_,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Space.md,
|
||||
vertical: Space.sm,
|
||||
vertical: Space.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search_rounded, color: context.colors.primary),
|
||||
Icon(
|
||||
Icons.search_rounded,
|
||||
size: Sizes.icon,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
const SizedBox(width: Space.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -323,16 +394,63 @@ class _Collapsed extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// الوجهات الأخيرة — نمط سيرو (`rp_favorites.dart: RpRecentsRow`).
|
||||
// أكثر الرحلات تتكرّر، وبلا هذا يعيد الراكب كتابة الاسم نفسه كل مرة.
|
||||
const _RecentsRow(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// صفّ أفقي للوجهات الأخيرة — يختفي كلياً حين لا توجد، فلا يترك فراغاً.
|
||||
class _RecentsRow extends StatelessWidget {
|
||||
const _RecentsRow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<RideCubit>();
|
||||
final places = cubit.recentPlaces;
|
||||
if (places.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Space.sm),
|
||||
child: SizedBox(
|
||||
height: Sizes.touchTarget,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: places.length,
|
||||
separatorBuilder: (context, i) => const SizedBox(width: Space.xs),
|
||||
itemBuilder: (context, i) {
|
||||
final place = places[i];
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.history_rounded,
|
||||
size: Sizes.iconSm,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
place.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onPressed: () => cubit.pickPlace(place, PickTarget.destination),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PickConfirm extends StatelessWidget {
|
||||
const _PickConfirm({required this.onConfirm});
|
||||
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user