fix(apps): إسقاط التطبيق عند توليد الخريطة — النقاط صارت طبقة فلاتر

## العطل
Unhandled Exception: This Annotation Manager has not been initialized
  → didUpdateWidget → diffCircles → addCircle
  → libc++abi: std::domain_error → SIGABRT

## السبب البنيوي
MapLibre يُنشئ مديري التعليقات (circle/symbol/line) **عند تحميل الستايل
وحده**، ويهدمهم عند كل إعادة تحميل — وتبديل الوضع الليلي إعادةُ تحميل.
وحارسٌ زمنيّ لا يكفي: didUpdateWidget في الحزمة يستدعي diffCircles بمجرّد
وجود المتحكّم، وهو موجود قبل تحميل الستايل وأثناء إعادة تحميله. أي أن أي
نافذة زمنية نتركها ستُصاب عاجلاً أو آجلاً.

## العلاج — إزالة صنف العطل لا ترقيعه
كل ما يُرسم فوق الخريطة صار **طبقة فلاتر** في Stack: نقطة البداية والنهاية
(MapPin) والأسماء (MapLabel) وأيقونة السائق، بمواضع من getScreenCoordinate.
لا annotation manager، فلا عطل — ومعها نرث الخط العربي والاتجاه والثيم.

Polyline وحده بقي annotation (لا بديل لرسم المسار)، محروساً بعَلَم _styleReady
يُصفَّر عند كل تغيير styleUrl.

## أداء
onCameraMove يُطلق عشرات المرات في الثانية وكل مزامنة ثلاثة نداءات منصّة —
أُضيف حارس _syncing يمنع تكدّسها.

## توثيق
docs/23 §13 قاعدة جديدة: ممنوع markers/circles/polygons مع IntaleqMap.
كُتبت كي لا تتكرر في أي شاشة خريطة قادمة.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-08-05 01:43:51 +03:00
co-authored by Claude Opus 5
parent ca766a2d4e
commit e2e663dd61
5 changed files with 425 additions and 240 deletions
@@ -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),
);
}
}
@@ -1,21 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im; import 'package:intaleq_maps/intaleq_maps.dart' as im;
import '../../../../core/config.dart'; import '../../../../core/config.dart';
import '../../../../core/design/tripz_colors.dart'; import '../../../../core/design/tripz_colors.dart';
import '../../data/models/geo_point.dart'; import '../../data/models/geo_point.dart';
import '../../data/models/route_info.dart'; import '../../data/models/route_info.dart';
import 'map_label.dart'; import 'map_label.dart';
import 'map_pin.dart';
/// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل
/// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً.
/// ///
/// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق /// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق
/// (قرار 2026-07-20). /// (قرار 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 { class RideMap extends StatefulWidget {
const RideMap({ const RideMap({
super.key, super.key,
@@ -53,25 +65,43 @@ class RideMap extends StatefulWidget {
class _RideMapState extends State<RideMap> { class _RideMapState extends State<RideMap> {
im.IntaleqMapController? _controller; im.IntaleqMapController? _controller;
im.LatLng? _center; 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); // عمّان static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
@override @override
void didUpdateWidget(RideMap old) { void didUpdateWidget(RideMap old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
if (!_isStyleLoaded) return;
if (widget.route != null && widget.route != old.route) { if (widget.route != null && widget.route != old.route) {
_fitRoute(); _fitRoute();
return; } else {
}
final target = widget.cameraTarget; final target = widget.cameraTarget;
if (target != null && target != old.cameraTarget) _moveTo(target); if (target != null && target != old.cameraTarget) _moveTo(target);
} }
// النقاط قد تتغيّر بلا حركة كاميرا — نعيد حساب مواضعها.
if (widget.origin != old.origin ||
widget.destination != old.destination ||
widget.driver != old.driver) {
_syncPixels();
}
}
/// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء /// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في
/// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على /// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات.
/// الاحتياطي. هنا نلحق بما فات.
void _onCreated(im.IntaleqMapController controller) { void _onCreated(im.IntaleqMapController controller) {
_controller = controller; _controller = controller;
final target = widget.cameraTarget ?? widget.origin; final target = widget.cameraTarget ?? widget.origin;
@@ -82,9 +112,17 @@ class _RideMapState extends State<RideMap> {
} }
} }
void _moveTo(GeoPoint p) => _controller?.animateCamera( 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), im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
); );
}
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
/// ///
@@ -119,44 +157,40 @@ class _RideMapState extends State<RideMap> {
); );
} }
/// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع /// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها.
/// البطاقاتُ الخريطةَ بلا حساب في كل إطار. Future<void> _syncPixels() async {
Offset? _originPx;
Offset? _destPx;
Future<void> _syncLabelPositions() async {
final controller = _controller; final controller = _controller;
if (controller == null) return; if (controller == null || _syncing) return;
final origin = widget.origin; _syncing = true;
final dest = widget.destination;
Future<Offset?> 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 { try {
final o = origin == null final results = await Future.wait([
? null px(widget.origin),
: await controller px(widget.destination),
.getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); px(widget.driver),
final d = dest == null ]);
? null
: await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng));
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); _originPx = results[0];
_destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); _destPx = results[1];
_driverPx = results[2];
}); });
} catch (_) { } 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 @override
@@ -164,7 +198,16 @@ class _RideMapState extends State<RideMap> {
final colors = context.tripzColors; final colors = context.tripzColors;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Stack(children: [ // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
// الغرض (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( im.IntaleqMap(
apiKey: AppConfig.mapApiKey, apiKey: AppConfig.mapApiKey,
initialCameraPosition: im.CameraPosition( initialCameraPosition: im.CameraPosition(
@@ -173,73 +216,68 @@ class _RideMapState extends State<RideMap> {
: im.LatLng(widget.origin!.lat, widget.origin!.lng), : im.LatLng(widget.origin!.lat, widget.origin!.lng),
zoom: 15, zoom: 15,
), ),
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر styleUrl: styleUrl,
// الغرض (docs/26 §2).
styleUrl: isDark
? im.IntaleqStyles.obsidian(AppConfig.mapApiKey)
: im.IntaleqStyles.light(AppConfig.mapApiKey),
myLocationEnabled: true, myLocationEnabled: true,
compassEnabled: false, compassEnabled: false,
zoomControlsEnabled: false, zoomControlsEnabled: false,
onMapCreated: _onCreated, onMapCreated: _onCreated,
onStyleLoaded: () { onStyleLoaded: _onStyleLoaded,
if (mounted && !_isStyleLoaded) { onCameraMove: (position) {
setState(() => _isStyleLoaded = true); _center = position.target;
} // الطبقة تتبع الخريطة أثناء السحب لا بعده فقط.
_syncPixels();
}, },
onCameraMove: (position) => _center = position.target,
onCameraIdle: () { onCameraIdle: () {
final c = _center; final c = _center;
if (c != null) { if (c != null) {
widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude));
} }
_syncLabelPositions(); _syncPixels();
}, },
// العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
// فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. polylines: _styleReady && widget.route != null &&
circles: _isStyleLoaded widget.route!.encodedPoints.isNotEmpty
? { ? {
if (widget.origin != null)
_dot('origin', widget.origin!, colors.mapOrigin),
if (widget.destination != null)
_dot('destination', widget.destination!, colors.mapDestination),
}
: const {},
markers: _isStyleLoaded
? {
if (widget.driver != null) _marker('driver', widget.driver!),
}
: const {},
polylines: _isStyleLoaded
? {
if (widget.route != null && widget.route!.encodedPoints.isNotEmpty)
im.Polyline( im.Polyline(
polylineId: const im.PolylineId('route'), polylineId: const im.PolylineId('route'),
points: im.PolylineUtils.decode(widget.route!.encodedPoints), points: im.PolylineUtils.decode(
widget.route!.encodedPoints,
),
color: colors.mapRoute, color: colors.mapRoute,
width: 5, width: 5,
), ),
} }
: const {}, : const {},
), ),
// أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي
// نرث الخط والاتجاه والثيم. // ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
_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(_originPx, widget.originLabel, colors.mapOrigin),
_label(_destPx, widget.destinationLabel, colors.mapDestination), _label(_destPx, widget.destinationLabel, colors.mapDestination),
]); ],
);
} }
im.Marker _marker(String id, GeoPoint p) => im.Marker( Widget _pin(Offset? px, Color color, IconData? icon) {
markerId: im.MarkerId(id), if (px == null) return const SizedBox.shrink();
position: im.LatLng(p.lat, p.lng), 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( Widget _label(Offset? px, String text, Color color) {
circleId: im.CircleId(id), if (px == null || text.isEmpty) return const SizedBox.shrink();
center: im.LatLng(p.lat, p.lng), return Positioned(
radius: 18, left: px.dx - 100,
fillColor: color, top: px.dy - 52,
strokeColor: Colors.white, width: 200,
strokeWidth: 3, child: IgnorePointer(
child: Center(child: MapLabel(text: text, color: color)),
),
); );
}
} }
@@ -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),
);
}
}
@@ -1,21 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im; import 'package:intaleq_maps/intaleq_maps.dart' as im;
import '../../../../core/config.dart'; import '../../../../core/config.dart';
import '../../../../core/design/tripz_colors.dart'; import '../../../../core/design/tripz_colors.dart';
import '../../data/models/geo_point.dart'; import '../../data/models/geo_point.dart';
import '../../data/models/route_info.dart'; import '../../data/models/route_info.dart';
import 'map_label.dart'; import 'map_label.dart';
import 'map_pin.dart';
/// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل
/// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً.
/// ///
/// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق /// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق
/// (قرار 2026-07-20). /// (قرار 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 { class RideMap extends StatefulWidget {
const RideMap({ const RideMap({
super.key, super.key,
@@ -53,25 +65,43 @@ class RideMap extends StatefulWidget {
class _RideMapState extends State<RideMap> { class _RideMapState extends State<RideMap> {
im.IntaleqMapController? _controller; im.IntaleqMapController? _controller;
im.LatLng? _center; 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); // عمّان static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
@override @override
void didUpdateWidget(RideMap old) { void didUpdateWidget(RideMap old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
if (!_isStyleLoaded) return;
if (widget.route != null && widget.route != old.route) { if (widget.route != null && widget.route != old.route) {
_fitRoute(); _fitRoute();
return; } else {
}
final target = widget.cameraTarget; final target = widget.cameraTarget;
if (target != null && target != old.cameraTarget) _moveTo(target); if (target != null && target != old.cameraTarget) _moveTo(target);
} }
// النقاط قد تتغيّر بلا حركة كاميرا — نعيد حساب مواضعها.
if (widget.origin != old.origin ||
widget.destination != old.destination ||
widget.driver != old.driver) {
_syncPixels();
}
}
/// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء /// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في
/// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على /// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات.
/// الاحتياطي. هنا نلحق بما فات.
void _onCreated(im.IntaleqMapController controller) { void _onCreated(im.IntaleqMapController controller) {
_controller = controller; _controller = controller;
final target = widget.cameraTarget ?? widget.origin; final target = widget.cameraTarget ?? widget.origin;
@@ -82,9 +112,17 @@ class _RideMapState extends State<RideMap> {
} }
} }
void _moveTo(GeoPoint p) => _controller?.animateCamera( 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), im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
); );
}
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
/// ///
@@ -119,44 +157,40 @@ class _RideMapState extends State<RideMap> {
); );
} }
/// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع /// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها.
/// البطاقاتُ الخريطةَ بلا حساب في كل إطار. Future<void> _syncPixels() async {
Offset? _originPx;
Offset? _destPx;
Future<void> _syncLabelPositions() async {
final controller = _controller; final controller = _controller;
if (controller == null) return; if (controller == null || _syncing) return;
final origin = widget.origin; _syncing = true;
final dest = widget.destination;
Future<Offset?> 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 { try {
final o = origin == null final results = await Future.wait([
? null px(widget.origin),
: await controller px(widget.destination),
.getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); px(widget.driver),
final d = dest == null ]);
? null
: await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng));
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); _originPx = results[0];
_destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); _destPx = results[1];
_driverPx = results[2];
}); });
} catch (_) { } 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 @override
@@ -164,7 +198,16 @@ class _RideMapState extends State<RideMap> {
final colors = context.tripzColors; final colors = context.tripzColors;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Stack(children: [ // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
// الغرض (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( im.IntaleqMap(
apiKey: AppConfig.mapApiKey, apiKey: AppConfig.mapApiKey,
initialCameraPosition: im.CameraPosition( initialCameraPosition: im.CameraPosition(
@@ -173,73 +216,68 @@ class _RideMapState extends State<RideMap> {
: im.LatLng(widget.origin!.lat, widget.origin!.lng), : im.LatLng(widget.origin!.lat, widget.origin!.lng),
zoom: 15, zoom: 15,
), ),
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر styleUrl: styleUrl,
// الغرض (docs/26 §2).
styleUrl: isDark
? im.IntaleqStyles.obsidian(AppConfig.mapApiKey)
: im.IntaleqStyles.light(AppConfig.mapApiKey),
myLocationEnabled: true, myLocationEnabled: true,
compassEnabled: false, compassEnabled: false,
zoomControlsEnabled: false, zoomControlsEnabled: false,
onMapCreated: _onCreated, onMapCreated: _onCreated,
onStyleLoaded: () { onStyleLoaded: _onStyleLoaded,
if (mounted && !_isStyleLoaded) { onCameraMove: (position) {
setState(() => _isStyleLoaded = true); _center = position.target;
} // الطبقة تتبع الخريطة أثناء السحب لا بعده فقط.
_syncPixels();
}, },
onCameraMove: (position) => _center = position.target,
onCameraIdle: () { onCameraIdle: () {
final c = _center; final c = _center;
if (c != null) { if (c != null) {
widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude));
} }
_syncLabelPositions(); _syncPixels();
}, },
// العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
// فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. polylines: _styleReady && widget.route != null &&
circles: _isStyleLoaded widget.route!.encodedPoints.isNotEmpty
? { ? {
if (widget.origin != null)
_dot('origin', widget.origin!, colors.mapOrigin),
if (widget.destination != null)
_dot('destination', widget.destination!, colors.mapDestination),
}
: const {},
markers: _isStyleLoaded
? {
if (widget.driver != null) _marker('driver', widget.driver!),
}
: const {},
polylines: _isStyleLoaded
? {
if (widget.route != null && widget.route!.encodedPoints.isNotEmpty)
im.Polyline( im.Polyline(
polylineId: const im.PolylineId('route'), polylineId: const im.PolylineId('route'),
points: im.PolylineUtils.decode(widget.route!.encodedPoints), points: im.PolylineUtils.decode(
widget.route!.encodedPoints,
),
color: colors.mapRoute, color: colors.mapRoute,
width: 5, width: 5,
), ),
} }
: const {}, : const {},
), ),
// أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي
// نرث الخط والاتجاه والثيم. // ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
_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(_originPx, widget.originLabel, colors.mapOrigin),
_label(_destPx, widget.destinationLabel, colors.mapDestination), _label(_destPx, widget.destinationLabel, colors.mapDestination),
]); ],
);
} }
im.Marker _marker(String id, GeoPoint p) => im.Marker( Widget _pin(Offset? px, Color color, IconData? icon) {
markerId: im.MarkerId(id), if (px == null) return const SizedBox.shrink();
position: im.LatLng(p.lat, p.lng), 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( Widget _label(Offset? px, String text, Color color) {
circleId: im.CircleId(id), if (px == null || text.isEmpty) return const SizedBox.shrink();
center: im.LatLng(p.lat, p.lng), return Positioned(
radius: 18, left: px.dx - 100,
fillColor: color, top: px.dy - 52,
strokeColor: Colors.white, width: 200,
strokeWidth: 3, child: IgnorePointer(
child: Center(child: MapLabel(text: text, color: color)),
),
); );
}
} }
+27
View File
@@ -222,3 +222,30 @@ if (Features.chat) routes.add(GoRoute(path: '/chat', builder: ...));
3. **صمام الأمان الثالث: المصالحة (reconciliation).** `GET /trips/:id` تُنادى عند: (أ) عودة التطبيق من الخلفية، (ب) إعادة اتصال السوكت بعد انقطاع، (ج) مؤقّت خفيف **أثناء رحلة نشطة فقط** (افتراضي كل 25 ثانية، قابل للضبط، يتوقف كلياً خارج الرحلة). سؤال المالك «هل يلزم كل فترة؟» — الجواب نعم لكن بهذا الشرط الضيّق: FCM قد يتأخر دقائق في Doze والسوكت قد يموت صامتاً؛ المؤقّت يغطي نافذة فشل القناتين معاً، وكلفته استعلام Redis واحد (A3). 3. **صمام الأمان الثالث: المصالحة (reconciliation).** `GET /trips/:id` تُنادى عند: (أ) عودة التطبيق من الخلفية، (ب) إعادة اتصال السوكت بعد انقطاع، (ج) مؤقّت خفيف **أثناء رحلة نشطة فقط** (افتراضي كل 25 ثانية، قابل للضبط، يتوقف كلياً خارج الرحلة). سؤال المالك «هل يلزم كل فترة؟» — الجواب نعم لكن بهذا الشرط الضيّق: FCM قد يتأخر دقائق في Doze والسوكت قد يموت صامتاً؛ المؤقّت يغطي نافذة فشل القناتين معاً، وكلفته استعلام Redis واحد (A3).
4. **الفتح البارد**: `GET /trips/mine` (استئناف أي رحلة غير منتهية) + `GET /trips/rating/pending` (فرض شاشة التقييم قبل أي رحلة جديدة). 4. **الفتح البارد**: `GET /trips/mine` (استئناف أي رحلة غير منتهية) + `GET /trips/rating/pending` (فرض شاشة التقييم قبل أي رحلة جديدة).
5. **العرض دائماً من حالة الـBloc الموحّدة** — لا شاشة تستمع للسوكت أو FCM مباشرة. 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`.