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:
co-authored by
Claude Opus 5
parent
ca766a2d4e
commit
e2e663dd61
@@ -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)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user