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: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<RideMap> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء
|
||||
/// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على
|
||||
/// الاحتياطي. هنا نلحق بما فات.
|
||||
/// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في
|
||||
/// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات.
|
||||
void _onCreated(im.IntaleqMapController controller) {
|
||||
_controller = controller;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
|
||||
///
|
||||
@@ -119,44 +157,40 @@ class _RideMapState extends State<RideMap> {
|
||||
);
|
||||
}
|
||||
|
||||
/// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع
|
||||
/// البطاقاتُ الخريطةَ بلا حساب في كل إطار.
|
||||
Offset? _originPx;
|
||||
Offset? _destPx;
|
||||
|
||||
Future<void> _syncLabelPositions() async {
|
||||
/// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها.
|
||||
Future<void> _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<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 {
|
||||
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
|
||||
@@ -164,7 +198,16 @@ class _RideMapState extends State<RideMap> {
|
||||
final colors = context.tripzColors;
|
||||
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(
|
||||
apiKey: AppConfig.mapApiKey,
|
||||
initialCameraPosition: im.CameraPosition(
|
||||
@@ -173,73 +216,68 @@ class _RideMapState extends State<RideMap> {
|
||||
: 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),
|
||||
styleUrl: styleUrl,
|
||||
myLocationEnabled: true,
|
||||
compassEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
onMapCreated: _onCreated,
|
||||
onStyleLoaded: () {
|
||||
if (mounted && !_isStyleLoaded) {
|
||||
setState(() => _isStyleLoaded = true);
|
||||
}
|
||||
onStyleLoaded: _onStyleLoaded,
|
||||
onCameraMove: (position) {
|
||||
_center = position.target;
|
||||
// الطبقة تتبع الخريطة أثناء السحب لا بعده فقط.
|
||||
_syncPixels();
|
||||
},
|
||||
onCameraMove: (position) => _center = position.target,
|
||||
onCameraIdle: () {
|
||||
final c = _center;
|
||||
if (c != null) {
|
||||
widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude));
|
||||
}
|
||||
_syncLabelPositions();
|
||||
_syncPixels();
|
||||
},
|
||||
// العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية،
|
||||
// فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح.
|
||||
circles: _isStyleLoaded
|
||||
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
|
||||
polylines: _styleReady && widget.route != null &&
|
||||
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(
|
||||
polylineId: const im.PolylineId('route'),
|
||||
points: im.PolylineUtils.decode(widget.route!.encodedPoints),
|
||||
points: im.PolylineUtils.decode(
|
||||
widget.route!.encodedPoints,
|
||||
),
|
||||
color: colors.mapRoute,
|
||||
width: 5,
|
||||
),
|
||||
}
|
||||
: 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(_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),
|
||||
);
|
||||
|
||||
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 _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)),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: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<RideMap> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء
|
||||
/// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على
|
||||
/// الاحتياطي. هنا نلحق بما فات.
|
||||
/// الموقع الحالي غالباً يصل *قبل* إنشاء الخريطة، فالتحريك في
|
||||
/// `didUpdateWidget` وحده يضيع وتبقى الكاميرا على الاحتياطي. هنا نلحق بما فات.
|
||||
void _onCreated(im.IntaleqMapController controller) {
|
||||
_controller = controller;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
|
||||
///
|
||||
@@ -119,44 +157,40 @@ class _RideMapState extends State<RideMap> {
|
||||
);
|
||||
}
|
||||
|
||||
/// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع
|
||||
/// البطاقاتُ الخريطةَ بلا حساب في كل إطار.
|
||||
Offset? _originPx;
|
||||
Offset? _destPx;
|
||||
|
||||
Future<void> _syncLabelPositions() async {
|
||||
/// يحوّل الإحداثيات إلى بكسلات الشاشة لرسم الطبقة الفلاترية فوقها.
|
||||
Future<void> _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<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 {
|
||||
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
|
||||
@@ -164,7 +198,16 @@ class _RideMapState extends State<RideMap> {
|
||||
final colors = context.tripzColors;
|
||||
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(
|
||||
apiKey: AppConfig.mapApiKey,
|
||||
initialCameraPosition: im.CameraPosition(
|
||||
@@ -173,73 +216,68 @@ class _RideMapState extends State<RideMap> {
|
||||
: 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),
|
||||
styleUrl: styleUrl,
|
||||
myLocationEnabled: true,
|
||||
compassEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
onMapCreated: _onCreated,
|
||||
onStyleLoaded: () {
|
||||
if (mounted && !_isStyleLoaded) {
|
||||
setState(() => _isStyleLoaded = true);
|
||||
}
|
||||
onStyleLoaded: _onStyleLoaded,
|
||||
onCameraMove: (position) {
|
||||
_center = position.target;
|
||||
// الطبقة تتبع الخريطة أثناء السحب لا بعده فقط.
|
||||
_syncPixels();
|
||||
},
|
||||
onCameraMove: (position) => _center = position.target,
|
||||
onCameraIdle: () {
|
||||
final c = _center;
|
||||
if (c != null) {
|
||||
widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude));
|
||||
}
|
||||
_syncLabelPositions();
|
||||
_syncPixels();
|
||||
},
|
||||
// العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية،
|
||||
// فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح.
|
||||
circles: _isStyleLoaded
|
||||
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
|
||||
polylines: _styleReady && widget.route != null &&
|
||||
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(
|
||||
polylineId: const im.PolylineId('route'),
|
||||
points: im.PolylineUtils.decode(widget.route!.encodedPoints),
|
||||
points: im.PolylineUtils.decode(
|
||||
widget.route!.encodedPoints,
|
||||
),
|
||||
color: colors.mapRoute,
|
||||
width: 5,
|
||||
),
|
||||
}
|
||||
: 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(_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),
|
||||
);
|
||||
|
||||
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 _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)),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user