diff --git a/apps/driver_new/lib/features/trip/view/widgets/map_pin.dart b/apps/driver_new/lib/features/trip/view/widgets/map_pin.dart new file mode 100644 index 0000000..193e1e6 --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/map_pin.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tripz_colors.dart'; + +/// نقطة على الخريطة — **طبقة فلاتر لا annotation**. +/// +/// السبب في `ride_map.dart`: مديرو تعليقات MapLibre يُهدمون مع كل إعادة +/// تحميل ستايل، وإضافة دائرة في تلك النافذة تُسقط التطبيق من الطبقة الأصلية. +/// رسمها بفلاتر يزيل هذا الصنف من الأعطال كلياً، ويعطينا الثيم والحدّ الأبيض +/// مجاناً. +class MapPin extends StatelessWidget { + const MapPin({super.key, required this.color, this.icon, this.size = 28}); + + final Color color; + final IconData? icon; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + // حدّ أبيض يفصل النقطة عن أي لون بلاطة تحتها. + border: Border.all(color: context.tripzColors.surfaceRaised, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: icon == null + ? null + : Icon(icon, size: size * 0.5, color: Colors.white), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart index 78b1b24..75c2420 100644 --- a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart @@ -1,21 +1,33 @@ import 'package:flutter/material.dart'; - import 'package:intaleq_maps/intaleq_maps.dart' as im; import '../../../../core/config.dart'; - import '../../../../core/design/tripz_colors.dart'; - import '../../data/models/geo_point.dart'; - import '../../data/models/route_info.dart'; import 'map_label.dart'; +import 'map_pin.dart'; /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. /// /// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق /// (قرار 2026-07-20). +/// +/// ## لماذا النقاط مرسومة بفلاتر لا كـ annotations +/// +/// MapLibre يُنشئ «مديري التعليقات» (circle/symbol/line) **عند تحميل الستايل +/// وحده**، ويهدمهم عند كل إعادة تحميل له — وتبديل الوضع الليلي إعادةُ تحميل. +/// وأي `addCircle` في تلك النافذة يرمي +/// `This Annotation Manager has not been initialized`، وقد رأيناه يُسقط +/// التطبيق بـ`std::domain_error` من الطبقة الأصلية (2026-08-05). +/// +/// حارسٌ زمنيّ لا يكفي: `didUpdateWidget` في الحزمة يستدعي `diffCircles` +/// بمجرّد وجود المتحكّم، وهو موجود قبل تحميل الستايل وأثناء إعادة تحميله. +/// +/// لذلك: **الدائرتان والاسمان والسائق كلهم طبقة فلاتر** فوق الخريطة، محسوبة +/// من `getScreenCoordinate`. المسار وحده يبقى `Polyline` لأنه لا بديل له، +/// ومحروسٌ بعَلَم يُصفَّر عند كل تغيير ستايل. class RideMap extends StatefulWidget { const RideMap({ super.key, @@ -53,25 +65,43 @@ class RideMap extends StatefulWidget { class _RideMapState extends State { im.IntaleqMapController? _controller; im.LatLng? _center; - bool _isStyleLoaded = false; + + /// يُصفَّر عند كل تغيير ستايل: المديرون يُهدمون وتُعاد تهيئتهم. + bool _styleReady = false; + + String? _styleUrl; + + /// مواضع النقاط على الشاشة — تُحدَّث مع حركة الكاميرا. + Offset? _originPx; + Offset? _destPx; + Offset? _driverPx; + + /// حارس تزامن: `onCameraMove` يُطلق عشرات المرات في الثانية أثناء السحب، + /// وكل مزامنة ثلاثة نداءات منصّة. بلا هذا الحارس تتكدّس النداءات وتلتهم + /// الإطارات — وهو بالضبط ما تمنعه ميزانية الأداء (docs/37 م2.12). + bool _syncing = false; static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); - if (!_isStyleLoaded) return; if (widget.route != null && widget.route != old.route) { _fitRoute(); - return; + } else { + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) _moveTo(target); + } + // النقاط قد تتغيّر بلا حركة كاميرا — نعيد حساب مواضعها. + if (widget.origin != old.origin || + widget.destination != old.destination || + widget.driver != old.driver) { + _syncPixels(); } - final target = widget.cameraTarget; - if (target != null && target != old.cameraTarget) _moveTo(target); } - /// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء - /// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على - /// الاحتياطي. هنا نلحق بما فات. + /// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في + /// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات. void _onCreated(im.IntaleqMapController controller) { _controller = controller; final target = widget.cameraTarget ?? widget.origin; @@ -82,9 +112,17 @@ class _RideMapState extends State { } } - void _moveTo(GeoPoint p) => _controller?.animateCamera( - im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), - ); + void _onStyleLoaded() { + if (!mounted) return; + setState(() => _styleReady = true); + _syncPixels(); + } + + void _moveTo(GeoPoint p) { + _controller?.animateCamera( + im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), + ); + } /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. /// @@ -119,127 +157,127 @@ class _RideMapState extends State { ); } - /// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع - /// البطاقاتُ الخريطةَ بلا حساب في كل إطار. - Offset? _originPx; - Offset? _destPx; - - Future _syncLabelPositions() async { + /// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها. + Future _syncPixels() async { final controller = _controller; - if (controller == null) return; - final origin = widget.origin; - final dest = widget.destination; + if (controller == null || _syncing) return; + _syncing = true; + + Future px(GeoPoint? p) async { + if (p == null) return null; + final point = await controller.getScreenCoordinate( + im.LatLng(p.lat, p.lng), + ); + return Offset(point.x.toDouble(), point.y.toDouble()); + } + try { - final o = origin == null - ? null - : await controller - .getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); - final d = dest == null - ? null - : await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng)); + final results = await Future.wait([ + px(widget.origin), + px(widget.destination), + px(widget.driver), + ]); if (!mounted) return; setState(() { - _originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); - _destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); + _originPx = results[0]; + _destPx = results[1]; + _driverPx = results[2]; }); } catch (_) { - // الخريطة قد تكون بين حالتين — البطاقات تُخفى بدل أن تُرسم في مكان خطأ. - if (mounted) setState(() => _originPx = _destPx = null); + // الخريطة بين حالتين — تُخفى الطبقة بدل أن تُرسم في مكان خاطئ. + if (mounted) { + setState(() => _originPx = _destPx = _driverPx = null); + } + } finally { + _syncing = false; } } - /// بطاقة اسم فوق نقطة، مرفوعة عن الدائرة كي لا تغطّيها. - Widget _label(Offset? px, String text, Color color) { - if (px == null || text.isEmpty) return const SizedBox.shrink(); - return Positioned( - left: px.dx - 100, - top: px.dy - 46, - width: 200, - child: Center(child: MapLabel(text: text, color: color)), - ); - } - @override Widget build(BuildContext context) { final colors = context.tripzColors; final isDark = Theme.of(context).brightness == Brightness.dark; - return Stack(children: [ - im.IntaleqMap( - apiKey: AppConfig.mapApiKey, - initialCameraPosition: im.CameraPosition( - target: widget.origin == null - ? _fallback - : im.LatLng(widget.origin!.lat, widget.origin!.lng), - zoom: 15, - ), - // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر - // الغرض (docs/26 §2). - styleUrl: isDark - ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) - : im.IntaleqStyles.light(AppConfig.mapApiKey), - myLocationEnabled: true, - compassEnabled: false, - zoomControlsEnabled: false, - onMapCreated: _onCreated, - onStyleLoaded: () { - if (mounted && !_isStyleLoaded) { - setState(() => _isStyleLoaded = true); - } - }, - onCameraMove: (position) => _center = position.target, - onCameraIdle: () { - final c = _center; - if (c != null) { - widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); - } - _syncLabelPositions(); - }, - // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، - // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. - circles: _isStyleLoaded - ? { - if (widget.origin != null) - _dot('origin', widget.origin!, colors.mapOrigin), - if (widget.destination != null) - _dot('destination', widget.destination!, colors.mapDestination), + // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر + // الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم. + final styleUrl = isDark + ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) + : im.IntaleqStyles.light(AppConfig.mapApiKey); + if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false; + _styleUrl = styleUrl; + + return Stack( + children: [ + im.IntaleqMap( + apiKey: AppConfig.mapApiKey, + initialCameraPosition: im.CameraPosition( + target: widget.origin == null + ? _fallback + : im.LatLng(widget.origin!.lat, widget.origin!.lng), + zoom: 15, + ), + styleUrl: styleUrl, + myLocationEnabled: true, + compassEnabled: false, + zoomControlsEnabled: false, + onMapCreated: _onCreated, + onStyleLoaded: _onStyleLoaded, + onCameraMove: (position) { + _center = position.target; + // الطبقة تتبع الخريطة أثناء السحب لا بعده فقط. + _syncPixels(); + }, + onCameraIdle: () { + final c = _center; + if (c != null) { + widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); } - : const {}, - markers: _isStyleLoaded - ? { - if (widget.driver != null) _marker('driver', widget.driver!), - } - : const {}, - polylines: _isStyleLoaded - ? { - if (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 {}, - ), - // أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي - // نرث الخط والاتجاه والثيم. - _label(_originPx, widget.originLabel, colors.mapOrigin), - _label(_destPx, widget.destinationLabel, colors.mapDestination), - ]); + _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 {}, + ), + + // ── الطبقة الفلاترية: نقاط وأسماء وسائق ────────────────────────── + _pin(_originPx, colors.mapOrigin, null), + _pin(_destPx, colors.mapDestination, Icons.place_rounded), + _pin(_driverPx, colors.info, Icons.local_taxi_rounded), + _label(_originPx, widget.originLabel, colors.mapOrigin), + _label(_destPx, widget.destinationLabel, colors.mapDestination), + ], + ); } - im.Marker _marker(String id, GeoPoint p) => im.Marker( - markerId: im.MarkerId(id), - position: im.LatLng(p.lat, p.lng), - ); + Widget _pin(Offset? px, Color color, IconData? icon) { + if (px == null) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 14, + top: px.dy - 14, + child: IgnorePointer(child: MapPin(color: color, icon: icon)), + ); + } - im.Circle _dot(String id, GeoPoint p, Color color) => im.Circle( - circleId: im.CircleId(id), - center: im.LatLng(p.lat, p.lng), - radius: 18, - fillColor: color, - strokeColor: Colors.white, - strokeWidth: 3, - ); + Widget _label(Offset? px, String text, Color color) { + if (px == null || text.isEmpty) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 100, + top: px.dy - 52, + width: 200, + child: IgnorePointer( + child: Center(child: MapLabel(text: text, color: color)), + ), + ); + } } diff --git a/apps/rider_new/lib/features/trip/view/widgets/map_pin.dart b/apps/rider_new/lib/features/trip/view/widgets/map_pin.dart new file mode 100644 index 0000000..193e1e6 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/map_pin.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tripz_colors.dart'; + +/// نقطة على الخريطة — **طبقة فلاتر لا annotation**. +/// +/// السبب في `ride_map.dart`: مديرو تعليقات MapLibre يُهدمون مع كل إعادة +/// تحميل ستايل، وإضافة دائرة في تلك النافذة تُسقط التطبيق من الطبقة الأصلية. +/// رسمها بفلاتر يزيل هذا الصنف من الأعطال كلياً، ويعطينا الثيم والحدّ الأبيض +/// مجاناً. +class MapPin extends StatelessWidget { + const MapPin({super.key, required this.color, this.icon, this.size = 28}); + + final Color color; + final IconData? icon; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + // حدّ أبيض يفصل النقطة عن أي لون بلاطة تحتها. + border: Border.all(color: context.tripzColors.surfaceRaised, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: icon == null + ? null + : Icon(icon, size: size * 0.5, color: Colors.white), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart index 78b1b24..75c2420 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart @@ -1,21 +1,33 @@ import 'package:flutter/material.dart'; - import 'package:intaleq_maps/intaleq_maps.dart' as im; import '../../../../core/config.dart'; - import '../../../../core/design/tripz_colors.dart'; - import '../../data/models/geo_point.dart'; - import '../../data/models/route_info.dart'; import 'map_label.dart'; +import 'map_pin.dart'; /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. /// /// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق /// (قرار 2026-07-20). +/// +/// ## لماذا النقاط مرسومة بفلاتر لا كـ annotations +/// +/// MapLibre يُنشئ «مديري التعليقات» (circle/symbol/line) **عند تحميل الستايل +/// وحده**، ويهدمهم عند كل إعادة تحميل له — وتبديل الوضع الليلي إعادةُ تحميل. +/// وأي `addCircle` في تلك النافذة يرمي +/// `This Annotation Manager has not been initialized`، وقد رأيناه يُسقط +/// التطبيق بـ`std::domain_error` من الطبقة الأصلية (2026-08-05). +/// +/// حارسٌ زمنيّ لا يكفي: `didUpdateWidget` في الحزمة يستدعي `diffCircles` +/// بمجرّد وجود المتحكّم، وهو موجود قبل تحميل الستايل وأثناء إعادة تحميله. +/// +/// لذلك: **الدائرتان والاسمان والسائق كلهم طبقة فلاتر** فوق الخريطة، محسوبة +/// من `getScreenCoordinate`. المسار وحده يبقى `Polyline` لأنه لا بديل له، +/// ومحروسٌ بعَلَم يُصفَّر عند كل تغيير ستايل. class RideMap extends StatefulWidget { const RideMap({ super.key, @@ -53,25 +65,43 @@ class RideMap extends StatefulWidget { class _RideMapState extends State { im.IntaleqMapController? _controller; im.LatLng? _center; - bool _isStyleLoaded = false; + + /// يُصفَّر عند كل تغيير ستايل: المديرون يُهدمون وتُعاد تهيئتهم. + bool _styleReady = false; + + String? _styleUrl; + + /// مواضع النقاط على الشاشة — تُحدَّث مع حركة الكاميرا. + Offset? _originPx; + Offset? _destPx; + Offset? _driverPx; + + /// حارس تزامن: `onCameraMove` يُطلق عشرات المرات في الثانية أثناء السحب، + /// وكل مزامنة ثلاثة نداءات منصّة. بلا هذا الحارس تتكدّس النداءات وتلتهم + /// الإطارات — وهو بالضبط ما تمنعه ميزانية الأداء (docs/37 م2.12). + bool _syncing = false; static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); - if (!_isStyleLoaded) return; if (widget.route != null && widget.route != old.route) { _fitRoute(); - return; + } else { + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) _moveTo(target); + } + // النقاط قد تتغيّر بلا حركة كاميرا — نعيد حساب مواضعها. + if (widget.origin != old.origin || + widget.destination != old.destination || + widget.driver != old.driver) { + _syncPixels(); } - final target = widget.cameraTarget; - if (target != null && target != old.cameraTarget) _moveTo(target); } - /// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء - /// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على - /// الاحتياطي. هنا نلحق بما فات. + /// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في + /// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات. void _onCreated(im.IntaleqMapController controller) { _controller = controller; final target = widget.cameraTarget ?? widget.origin; @@ -82,9 +112,17 @@ class _RideMapState extends State { } } - void _moveTo(GeoPoint p) => _controller?.animateCamera( - im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), - ); + void _onStyleLoaded() { + if (!mounted) return; + setState(() => _styleReady = true); + _syncPixels(); + } + + void _moveTo(GeoPoint p) { + _controller?.animateCamera( + im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), + ); + } /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. /// @@ -119,127 +157,127 @@ class _RideMapState extends State { ); } - /// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع - /// البطاقاتُ الخريطةَ بلا حساب في كل إطار. - Offset? _originPx; - Offset? _destPx; - - Future _syncLabelPositions() async { + /// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها. + Future _syncPixels() async { final controller = _controller; - if (controller == null) return; - final origin = widget.origin; - final dest = widget.destination; + if (controller == null || _syncing) return; + _syncing = true; + + Future px(GeoPoint? p) async { + if (p == null) return null; + final point = await controller.getScreenCoordinate( + im.LatLng(p.lat, p.lng), + ); + return Offset(point.x.toDouble(), point.y.toDouble()); + } + try { - final o = origin == null - ? null - : await controller - .getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); - final d = dest == null - ? null - : await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng)); + final results = await Future.wait([ + px(widget.origin), + px(widget.destination), + px(widget.driver), + ]); if (!mounted) return; setState(() { - _originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); - _destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); + _originPx = results[0]; + _destPx = results[1]; + _driverPx = results[2]; }); } catch (_) { - // الخريطة قد تكون بين حالتين — البطاقات تُخفى بدل أن تُرسم في مكان خطأ. - if (mounted) setState(() => _originPx = _destPx = null); + // الخريطة بين حالتين — تُخفى الطبقة بدل أن تُرسم في مكان خاطئ. + if (mounted) { + setState(() => _originPx = _destPx = _driverPx = null); + } + } finally { + _syncing = false; } } - /// بطاقة اسم فوق نقطة، مرفوعة عن الدائرة كي لا تغطّيها. - Widget _label(Offset? px, String text, Color color) { - if (px == null || text.isEmpty) return const SizedBox.shrink(); - return Positioned( - left: px.dx - 100, - top: px.dy - 46, - width: 200, - child: Center(child: MapLabel(text: text, color: color)), - ); - } - @override Widget build(BuildContext context) { final colors = context.tripzColors; final isDark = Theme.of(context).brightness == Brightness.dark; - return Stack(children: [ - im.IntaleqMap( - apiKey: AppConfig.mapApiKey, - initialCameraPosition: im.CameraPosition( - target: widget.origin == null - ? _fallback - : im.LatLng(widget.origin!.lat, widget.origin!.lng), - zoom: 15, - ), - // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر - // الغرض (docs/26 §2). - styleUrl: isDark - ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) - : im.IntaleqStyles.light(AppConfig.mapApiKey), - myLocationEnabled: true, - compassEnabled: false, - zoomControlsEnabled: false, - onMapCreated: _onCreated, - onStyleLoaded: () { - if (mounted && !_isStyleLoaded) { - setState(() => _isStyleLoaded = true); - } - }, - onCameraMove: (position) => _center = position.target, - onCameraIdle: () { - final c = _center; - if (c != null) { - widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); - } - _syncLabelPositions(); - }, - // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، - // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. - circles: _isStyleLoaded - ? { - if (widget.origin != null) - _dot('origin', widget.origin!, colors.mapOrigin), - if (widget.destination != null) - _dot('destination', widget.destination!, colors.mapDestination), + // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر + // الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم. + final styleUrl = isDark + ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) + : im.IntaleqStyles.light(AppConfig.mapApiKey); + if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false; + _styleUrl = styleUrl; + + return Stack( + children: [ + im.IntaleqMap( + apiKey: AppConfig.mapApiKey, + initialCameraPosition: im.CameraPosition( + target: widget.origin == null + ? _fallback + : im.LatLng(widget.origin!.lat, widget.origin!.lng), + zoom: 15, + ), + styleUrl: styleUrl, + myLocationEnabled: true, + compassEnabled: false, + zoomControlsEnabled: false, + onMapCreated: _onCreated, + onStyleLoaded: _onStyleLoaded, + onCameraMove: (position) { + _center = position.target; + // الطبقة تتبع الخريطة أثناء السحب لا بعده فقط. + _syncPixels(); + }, + onCameraIdle: () { + final c = _center; + if (c != null) { + widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); } - : const {}, - markers: _isStyleLoaded - ? { - if (widget.driver != null) _marker('driver', widget.driver!), - } - : const {}, - polylines: _isStyleLoaded - ? { - if (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 {}, - ), - // أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي - // نرث الخط والاتجاه والثيم. - _label(_originPx, widget.originLabel, colors.mapOrigin), - _label(_destPx, widget.destinationLabel, colors.mapDestination), - ]); + _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 {}, + ), + + // ── الطبقة الفلاترية: نقاط وأسماء وسائق ────────────────────────── + _pin(_originPx, colors.mapOrigin, null), + _pin(_destPx, colors.mapDestination, Icons.place_rounded), + _pin(_driverPx, colors.info, Icons.local_taxi_rounded), + _label(_originPx, widget.originLabel, colors.mapOrigin), + _label(_destPx, widget.destinationLabel, colors.mapDestination), + ], + ); } - im.Marker _marker(String id, GeoPoint p) => im.Marker( - markerId: im.MarkerId(id), - position: im.LatLng(p.lat, p.lng), - ); + Widget _pin(Offset? px, Color color, IconData? icon) { + if (px == null) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 14, + top: px.dy - 14, + child: IgnorePointer(child: MapPin(color: color, icon: icon)), + ); + } - im.Circle _dot(String id, GeoPoint p, Color color) => im.Circle( - circleId: im.CircleId(id), - center: im.LatLng(p.lat, p.lng), - radius: 18, - fillColor: color, - strokeColor: Colors.white, - strokeWidth: 3, - ); + Widget _label(Offset? px, String text, Color color) { + if (px == null || text.isEmpty) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 100, + top: px.dy - 52, + width: 200, + child: IgnorePointer( + child: Center(child: MapLabel(text: text, color: color)), + ), + ); + } } diff --git a/docs/23-flutter-conventions.md b/docs/23-flutter-conventions.md index 807fa3f..bff3d00 100644 --- a/docs/23-flutter-conventions.md +++ b/docs/23-flutter-conventions.md @@ -222,3 +222,30 @@ if (Features.chat) routes.add(GoRoute(path: '/chat', builder: ...)); 3. **صمام الأمان الثالث: المصالحة (reconciliation).** `GET /trips/:id` تُنادى عند: (أ) عودة التطبيق من الخلفية، (ب) إعادة اتصال السوكت بعد انقطاع، (ج) مؤقّت خفيف **أثناء رحلة نشطة فقط** (افتراضي كل 25 ثانية، قابل للضبط، يتوقف كلياً خارج الرحلة). سؤال المالك «هل يلزم كل فترة؟» — الجواب نعم لكن بهذا الشرط الضيّق: FCM قد يتأخر دقائق في Doze والسوكت قد يموت صامتاً؛ المؤقّت يغطي نافذة فشل القناتين معاً، وكلفته استعلام Redis واحد (A3). 4. **الفتح البارد**: `GET /trips/mine` (استئناف أي رحلة غير منتهية) + `GET /trips/rating/pending` (فرض شاشة التقييم قبل أي رحلة جديدة). 5. **العرض دائماً من حالة الـBloc الموحّدة** — لا شاشة تستمع للسوكت أو FCM مباشرة. + +--- + +## 13. الخريطة — قاعدة مضافة 2026-08-05 (بعد سقوط التطبيق) + +**ممنوع تمرير `markers`/`circles`/`polygons` إلى `IntaleqMap`.** + +MapLibre يُنشئ «مديري التعليقات» عند تحميل الستايل وحده، ويهدمهم عند كل +إعادة تحميل — **وتبديل الوضع الليلي إعادةُ تحميل**. وأي إضافة تعليق في تلك +النافذة ترمي `This Annotation Manager has not been initialized`، وقد أسقطت +التطبيق فعلياً بـ`std::domain_error` من الطبقة الأصلية. + +حارسٌ زمنيّ لا يكفي: `didUpdateWidget` في الحزمة يستدعي `diffCircles` بمجرّد +وجود المتحكّم — وهو موجود **قبل** تحميل الستايل و**أثناء** إعادة تحميله. + +**القاعدة**: كل ما يُرسم فوق الخريطة (نقاط · أسماء · أيقونة السائق) يكون +**طبقة فلاتر** في `Stack` فوقها، بمواضع من `getScreenCoordinate`. هذا يزيل +صنف الأعطال كلياً، ويعطي الخط العربي والاتجاه والثيم مجاناً. + +**الاستثناء الوحيد `Polyline`** — لا بديل عنه لرسم المسار. يُحرس بعَلَم +`_styleReady` يُصفَّر عند كل تغيير `styleUrl`. + +**والأداء**: `onCameraMove` يُطلق عشرات المرات في الثانية، وكل مزامنة مواضع +ثلاثة نداءات منصّة. حارس `_syncing` إلزامي وإلا تتكدّس النداءات وتلتهم +الإطارات. + +المرجع العملي: `apps/rider_new/lib/features/trip/view/widgets/ride_map.dart`.