chore: add build artifacts, map assets, and routing data
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show Size;
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// [CameraSensorProfile] - الملف التعريفي لمستشعر الكاميرا وخصائصه الفيزيائية
|
||||
/// ============================================================================
|
||||
class CameraSensorProfile {
|
||||
final String deviceName;
|
||||
final double sensorWidthMm;
|
||||
final double sensorHeightMm;
|
||||
final double focalLengthMm;
|
||||
final double horizontalFovDegrees;
|
||||
final double verticalFovDegrees;
|
||||
final bool isCalibrated;
|
||||
|
||||
const CameraSensorProfile({
|
||||
required this.deviceName,
|
||||
required this.sensorWidthMm,
|
||||
required this.sensorHeightMm,
|
||||
required this.focalLengthMm,
|
||||
required this.horizontalFovDegrees,
|
||||
required this.verticalFovDegrees,
|
||||
this.isCalibrated = false,
|
||||
});
|
||||
|
||||
/// 35mm Equivalent Focal Length
|
||||
double get focalLength35mmEquiv {
|
||||
final diagSensor = math.sqrt(sensorWidthMm * sensorWidthMm + sensorHeightMm * sensorHeightMm);
|
||||
const diag35mm = 43.27; // Standard 35mm diagonal (36x24mm)
|
||||
final cropFactor = diag35mm / (diagSensor > 0 ? diagSensor : 1.0);
|
||||
return focalLengthMm * cropFactor;
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================================
|
||||
/// [TargetPreset] - صنف الهدف التكتيكي وارتفاعه الميداني المعتمد
|
||||
/// ============================================================================
|
||||
class TargetPreset {
|
||||
final String id;
|
||||
final String titleAr;
|
||||
final double heightMeters;
|
||||
final String iconCode;
|
||||
final String categoryAr;
|
||||
|
||||
const TargetPreset({
|
||||
required this.id,
|
||||
required this.titleAr,
|
||||
required this.heightMeters,
|
||||
required this.iconCode,
|
||||
required this.categoryAr,
|
||||
});
|
||||
}
|
||||
|
||||
/// ============================================================================
|
||||
/// [CameraSensorCalibrationService] - خدمة فحص ومعايرة مستشعر الكاميرا وحساب المدى
|
||||
/// ============================================================================
|
||||
class CameraSensorCalibrationService {
|
||||
// ── مكتبة الأهداف التكتيكية الميدانية ──────────────────────────────────────
|
||||
static const List<TargetPreset> standardTargets = [
|
||||
TargetPreset(
|
||||
id: 'infantry',
|
||||
titleAr: 'جندي مشاة / فرد راجل',
|
||||
heightMeters: 1.75,
|
||||
iconCode: 'person',
|
||||
categoryAr: 'أفراد',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'light_vehicle',
|
||||
titleAr: 'عربة عسكرية / جيب مصفح',
|
||||
heightMeters: 2.30,
|
||||
iconCode: 'directions_car',
|
||||
categoryAr: 'آليات',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'mrap_armored',
|
||||
titleAr: 'مدرعة ناقلة جند / MRAP',
|
||||
heightMeters: 2.80,
|
||||
iconCode: 'shield',
|
||||
categoryAr: 'آليات',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'mbt_tank',
|
||||
titleAr: 'دبابة قتال رئيسية (MBT)',
|
||||
heightMeters: 2.70,
|
||||
iconCode: 'fire_truck',
|
||||
categoryAr: 'آليات',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'flagpole_light',
|
||||
titleAr: 'عمود إنارة / سارية علم',
|
||||
heightMeters: 10.0,
|
||||
iconCode: 'flag',
|
||||
categoryAr: 'منشآت',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'house_2floor',
|
||||
titleAr: 'مبنى سكني من طابقين',
|
||||
heightMeters: 6.5,
|
||||
iconCode: 'home',
|
||||
categoryAr: 'مبانٍ',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'building_3floor',
|
||||
titleAr: 'مبنى من 3 طوابق',
|
||||
heightMeters: 9.5,
|
||||
iconCode: 'apartment',
|
||||
categoryAr: 'مبانٍ',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'minaret_neighborhood',
|
||||
titleAr: 'مئذنة مسجد حي (قصيرة/متوسطة)',
|
||||
heightMeters: 16.0,
|
||||
iconCode: 'location_city',
|
||||
categoryAr: 'معالم بارزة',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'minaret_grand',
|
||||
titleAr: 'مئذنة جامع كبير (رئيسية)',
|
||||
heightMeters: 24.0,
|
||||
iconCode: 'mosque',
|
||||
categoryAr: 'معالم بارزة',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'silo_watertower',
|
||||
titleAr: 'صومعة حبوب / خزان مياه مرتفع',
|
||||
heightMeters: 30.0,
|
||||
iconCode: 'water_drop',
|
||||
categoryAr: 'معالم استراتيجية',
|
||||
),
|
||||
TargetPreset(
|
||||
id: 'comms_radar_tower',
|
||||
titleAr: 'برج اتصالات / رادار رصد',
|
||||
heightMeters: 45.0,
|
||||
iconCode: 'cell_tower',
|
||||
categoryAr: 'معالم استراتيجية',
|
||||
),
|
||||
];
|
||||
|
||||
/// الملف الافتراضي للمستشعر القياسي للهواتف الحديثة
|
||||
static CameraSensorProfile currentProfile = const CameraSensorProfile(
|
||||
deviceName: 'مستشعر قياسي (Standard 1/2.55" Mobile Sensor)',
|
||||
sensorWidthMm: 5.76,
|
||||
sensorHeightMm: 4.29,
|
||||
focalLengthMm: 4.25,
|
||||
horizontalFovDegrees: 65.0,
|
||||
verticalFovDegrees: 51.0,
|
||||
isCalibrated: true,
|
||||
);
|
||||
|
||||
/// فحص واستنتاج خصائص المستشعر من كائن الكاميرا
|
||||
static CameraSensorProfile inspectSensor(CameraDescription camera, Size previewSize) {
|
||||
// محاولة استخراج زاوية الرؤية وحجم المستشعر بناءً على نسبة أبعاد المعاينة
|
||||
double aspect = previewSize.width > 0 && previewSize.height > 0
|
||||
? previewSize.width / previewSize.height
|
||||
: 16 / 9;
|
||||
|
||||
double hFov = 65.0; // زاوية الرؤية الأفقية الافتراضية للكاميرا الأساسية 1x (26mm eq)
|
||||
double vFov = 2 * math.atan(math.tan(hFov * math.pi / 360) / aspect) * 180 / math.pi;
|
||||
|
||||
currentProfile = CameraSensorProfile(
|
||||
deviceName: '${camera.name} (${camera.lensDirection.name.toUpperCase()})',
|
||||
sensorWidthMm: 5.76,
|
||||
sensorHeightMm: 5.76 / aspect,
|
||||
focalLengthMm: 4.25,
|
||||
horizontalFovDegrees: hFov,
|
||||
verticalFovDegrees: vFov,
|
||||
isCalibrated: true,
|
||||
);
|
||||
|
||||
debugPrint('Camera Sensor Inspected: ${currentProfile.deviceName}, HFOV: ${hFov.toStringAsFixed(1)}°');
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
/// تعيين إعدادات مستشعر مخصصة يدوياً
|
||||
static void setCustomProfile({
|
||||
required double sensorWidthMm,
|
||||
required double focalLengthMm,
|
||||
required double horizontalFovDegrees,
|
||||
String name = 'معايرة يدوية مخصصة',
|
||||
}) {
|
||||
double vFov = horizontalFovDegrees * (9 / 16);
|
||||
currentProfile = CameraSensorProfile(
|
||||
deviceName: name,
|
||||
sensorWidthMm: sensorWidthMm,
|
||||
sensorHeightMm: sensorWidthMm * (9 / 16),
|
||||
focalLengthMm: focalLengthMm,
|
||||
horizontalFovDegrees: horizontalFovDegrees,
|
||||
verticalFovDegrees: vFov,
|
||||
isCalibrated: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// ==========================================================================
|
||||
/// [calculateStadiametricDistance] - حساب المسافة البصرية الدقيقة
|
||||
/// ==========================================================================
|
||||
/// English:
|
||||
/// Calculates distance in meters using pinhole geometry:
|
||||
/// Distance = (Real Target Height * Focal Length in Pixels * Zoom) / Reticule Height in Pixels
|
||||
///
|
||||
/// العربية:
|
||||
/// احتساب المسافة بالأمتار بناءً على تشابه المثلثات والبعد البؤري للمستشعر:
|
||||
/// المسافة = (ارتفاع الهدف بالأمتار × البعد البؤري بالبكسل × معامل التقريب) / ارتفاع مؤشر الهدف بالبكسل
|
||||
static double calculateStadiametricDistance({
|
||||
required double targetRealHeightMeters,
|
||||
required double reticulePixelHeight,
|
||||
required double screenHeightPixels,
|
||||
required double zoomMultiplier,
|
||||
}) {
|
||||
if (reticulePixelHeight <= 0 || screenHeightPixels <= 0 || targetRealHeightMeters <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// حساب البعد البؤري المكافئ بالبكسل بناءً على زاوية الرؤية العمودية للمستشعر
|
||||
final vFovRad = currentProfile.verticalFovDegrees * (math.pi / 180.0);
|
||||
final focalLengthPixels = (screenHeightPixels / 2.0) / math.tan(vFovRad / 2.0);
|
||||
|
||||
// تطبيق معامل التقريب الفعلي (Optical + Digital Zoom)
|
||||
final effectiveFocalLength = focalLengthPixels * zoomMultiplier;
|
||||
|
||||
// المعادلة البصرية
|
||||
final distanceMeters = (targetRealHeightMeters * effectiveFocalLength) / reticulePixelHeight;
|
||||
return distanceMeters;
|
||||
}
|
||||
|
||||
/// ==========================================================================
|
||||
/// [calculateInclineDistance] - حساب المسافة المائلة والأفقية عبر زاوية الميل
|
||||
/// ==========================================================================
|
||||
/// English:
|
||||
/// Calculates horizontal distance and slant range from pitch inclination angle and relative elevation.
|
||||
///
|
||||
/// العربية:
|
||||
/// احتساب المسافة الأفقية والمائلة عند توجيه الكاميرا إلى قمة المعلم بزاوية ميل معينة.
|
||||
static Map<String, double> calculateInclineDistance({
|
||||
required double targetRealHeightMeters,
|
||||
required double pitchDegrees,
|
||||
}) {
|
||||
final pitchRad = pitchDegrees.abs() * (math.pi / 180.0);
|
||||
if (pitchRad < 0.001) {
|
||||
return {'groundDistance': 0.0, 'slantRange': 0.0};
|
||||
}
|
||||
|
||||
final groundDistance = targetRealHeightMeters / math.tan(pitchRad);
|
||||
final slantRange = targetRealHeightMeters / math.sin(pitchRad);
|
||||
|
||||
return {
|
||||
'groundDistance': groundDistance,
|
||||
'slantRange': slantRange,
|
||||
};
|
||||
}
|
||||
|
||||
/// تقدير هامش الخطأ المسموح (Margin of Error ±)
|
||||
static double estimateErrorMarginMeters(double distanceMeters, double zoomMultiplier) {
|
||||
// يتراوح الخطأ بين 1.5% عند التكبير العالي و 4.5% عند المدى البعيد بدون تكبير
|
||||
double factor = 0.035 / (math.sqrt(zoomMultiplier).clamp(1.0, 3.0));
|
||||
return distanceMeters * factor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
|
||||
import '../models/friendly_unit.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// [LocalNetworkTrackerService] - محرك التتبع المحلي للوحدات (Blue Force Tracking)
|
||||
/// ============================================================================
|
||||
/// English:
|
||||
/// Uses UDP Broadcast (Port 4545) to transmit and receive live GPS coordinates
|
||||
/// of friendly forces over a localized tactical MANET or Wi-Fi mesh network,
|
||||
/// entirely offline with zero dependency on cellular data.
|
||||
///
|
||||
/// العربية:
|
||||
/// محرك التتبع المحلي عبر شبكات الراديو أو الواي فاي (Mesh/MANET).
|
||||
/// يستخدم بروتوكول UDP Broadcast لإرسال واستقبال إحداثيات القوات الصديقة
|
||||
/// وعرضها على خريطة القائد ميدانياً بدون إنترنت.
|
||||
/// ============================================================================
|
||||
class LocalNetworkTrackerService extends GetxService {
|
||||
static const int broadcastPort = 4545;
|
||||
|
||||
// My Device Info
|
||||
final String myDeviceId = 'DEV_${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
|
||||
final String myCallsign = 'Alpha-1 (Commander)';
|
||||
final UnitRole myRole = UnitRole.commander;
|
||||
|
||||
// Rx Map of all active friendly units nearby
|
||||
final RxMap<String, FriendlyUnit> friendlyUnits = <String, FriendlyUnit>{}.obs;
|
||||
|
||||
RawDatagramSocket? _socket;
|
||||
Timer? _broadcastTimer;
|
||||
Timer? _cleanupTimer;
|
||||
|
||||
// The last known GPS position of this device
|
||||
LatLng? _currentPosition;
|
||||
double _currentHeading = 0.0;
|
||||
|
||||
void updateMyPosition(LatLng pos, double heading) {
|
||||
_currentPosition = pos;
|
||||
_currentHeading = heading;
|
||||
}
|
||||
|
||||
Future<LocalNetworkTrackerService> init() async {
|
||||
await _startListening();
|
||||
_startBroadcasting();
|
||||
_startCleanupRoutine();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// 1. Start Listening for UDP Packets from other radios/tablets
|
||||
Future<void> _startListening() async {
|
||||
try {
|
||||
_socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, broadcastPort);
|
||||
_socket?.broadcastEnabled = true;
|
||||
|
||||
_socket?.listen((RawSocketEvent event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final datagram = _socket?.receive();
|
||||
if (datagram != null) {
|
||||
_processIncomingPacket(datagram.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
debugPrint('🟢 Local BFT Listener started on port $broadcastPort');
|
||||
} catch (e) {
|
||||
debugPrint('🔴 Local BFT Listener error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 2. Process incoming JSON position reports
|
||||
void _processIncomingPacket(List<int> data) {
|
||||
try {
|
||||
final jsonStr = utf8.decode(data);
|
||||
final map = jsonDecode(jsonStr);
|
||||
final unit = FriendlyUnit.fromJson(map);
|
||||
|
||||
// Ignore our own broadcast loops
|
||||
if (unit.deviceId == myDeviceId) return;
|
||||
|
||||
// Update the reactive map
|
||||
friendlyUnits[unit.deviceId] = unit;
|
||||
} catch (e) {
|
||||
// Ignore malformed packets
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. Broadcast our position to the local network every 3 seconds
|
||||
void _startBroadcasting() {
|
||||
_broadcastTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||||
if (_currentPosition == null || _socket == null) return;
|
||||
|
||||
final me = FriendlyUnit(
|
||||
deviceId: myDeviceId,
|
||||
callsign: myCallsign,
|
||||
position: _currentPosition!,
|
||||
heading: _currentHeading,
|
||||
role: myRole,
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
|
||||
final data = utf8.encode(jsonEncode(me.toJson()));
|
||||
|
||||
try {
|
||||
// Send to universal broadcast address.
|
||||
// In a real tactical mesh network (TrellisWare/Silvus),
|
||||
// multicast or specific subnet broadcast is used.
|
||||
_socket?.send(data, InternetAddress('255.255.255.255'), broadcastPort);
|
||||
} catch (e) {
|
||||
// Broadcast failed (e.g. no network interface active)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 4. Cleanup old units (Remove units not seen for 30 seconds)
|
||||
void _startCleanupRoutine() {
|
||||
_cleanupTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
|
||||
final now = DateTime.now();
|
||||
final deadKeys = <String>[];
|
||||
|
||||
friendlyUnits.forEach((key, unit) {
|
||||
if (now.difference(unit.lastSeen).inSeconds > 30) {
|
||||
deadKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
for (var key in deadKeys) {
|
||||
friendlyUnits.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_broadcastTimer?.cancel();
|
||||
_cleanupTimer?.cancel();
|
||||
_socket?.close();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'offline_routing_engine.dart';
|
||||
import 'offline_routing_package_service.dart';
|
||||
|
||||
class _GraphEdge {
|
||||
final int toNode;
|
||||
final String name;
|
||||
final String highway;
|
||||
final double speedKmh;
|
||||
final double lengthM;
|
||||
final List<LatLng> coords;
|
||||
|
||||
const _GraphEdge({
|
||||
required this.toNode,
|
||||
required this.name,
|
||||
required this.highway,
|
||||
required this.speedKmh,
|
||||
required this.lengthM,
|
||||
required this.coords,
|
||||
});
|
||||
}
|
||||
|
||||
/// ============================================================================
|
||||
/// [OfflineRoadGraphEngine] - محرك التوجيه السيادي الميداني على شبكة الطرق الحقيقية
|
||||
/// ============================================================================
|
||||
/// English:
|
||||
/// 100% On-Device high-performance routing engine powered by local SQLite road network
|
||||
/// (jordan_roads.db). Contains 1.99M real OSM nodes and 667K routable edges with real
|
||||
/// street names, speed limits, oneway constraints, and exact road curvature geometry.
|
||||
///
|
||||
/// Key improvements over previous version:
|
||||
/// - Snaps to CONNECTED nodes only (nodes that are actual edge endpoints)
|
||||
/// - Hierarchical routing: major highways for long distance + local streets near endpoints
|
||||
/// - Priority queue (min-heap) A* instead of linear scan openSet
|
||||
/// - Proper turn-by-turn maneuver generation with Arabic instructions
|
||||
///
|
||||
/// العربية:
|
||||
/// محرك التوجيه والملاحة السيادي المحلي 100% المعتمد على شبكة طرق الأردن الحقيقية (SQLite).
|
||||
/// يعمل بدون أي اتصال بالإنترنت، يقرأ بيانات الشوارع والتقاطعات والسرعات والانحناءات الفعلية.
|
||||
/// يحتوي على 1.99 مليون عقدة و667 ألف حافة طريق حقيقية مع أسماء شوارع عربية وسرعات فعلية.
|
||||
/// ============================================================================
|
||||
class OfflineRoadGraphEngine {
|
||||
static Database? _db;
|
||||
static String? _loadedDbPath;
|
||||
|
||||
/// Check if the SQLite road network database is installed locally
|
||||
static Future<bool> isDatabaseAvailable() async {
|
||||
final file = await _getDbFile();
|
||||
return file != null && await file.exists();
|
||||
}
|
||||
|
||||
static Future<File?> _getDbFile() async {
|
||||
final dir = await OfflineRoutingPackageService.installDir();
|
||||
final dbFile = File('${dir.path}/jordan_roads.db');
|
||||
if (await dbFile.exists()) return dbFile;
|
||||
|
||||
// Check parent or subfolders
|
||||
if (await dir.exists()) {
|
||||
for (final entity in dir.listSync(recursive: true)) {
|
||||
if (entity is File && entity.path.endsWith('jordan_roads.db')) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Database?> _getDatabase() async {
|
||||
if (_db != null && _db!.isOpen) return _db;
|
||||
|
||||
final dbFile = await _getDbFile();
|
||||
if (dbFile == null || !await dbFile.exists()) return null;
|
||||
|
||||
try {
|
||||
_db = await openDatabase(dbFile.path);
|
||||
_loadedDbPath = dbFile.path;
|
||||
debugPrint('OfflineRoadGraphEngine: Connected to local road network database at $_loadedDbPath');
|
||||
return _db;
|
||||
} catch (e) {
|
||||
debugPrint('OfflineRoadGraphEngine: Error opening SQLite roads database: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate 100% Offline Route following real road network curves
|
||||
static Future<OfflineRoutePlan?> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng destination,
|
||||
TacticalVehicleProfile profile = TacticalVehicleProfile.convoy,
|
||||
}) async {
|
||||
final db = await _getDatabase();
|
||||
if (db == null) return null;
|
||||
|
||||
try {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
// 1. Find nearest CONNECTED road nodes (nodes that are edge endpoints)
|
||||
final startSnap = await _snapToConnectedNode(db, start);
|
||||
final destSnap = await _snapToConnectedNode(db, destination);
|
||||
|
||||
if (startSnap == null || destSnap == null) {
|
||||
debugPrint('OfflineRoadGraphEngine: Failed to snap start or destination to road network');
|
||||
return null;
|
||||
}
|
||||
|
||||
final startNodeId = startSnap['id'] as int;
|
||||
final destNodeId = destSnap['id'] as int;
|
||||
final startNodePos = LatLng(startSnap['lat'] as double, startSnap['lng'] as double);
|
||||
final destNodePos = LatLng(destSnap['lat'] as double, destSnap['lng'] as double);
|
||||
|
||||
// 2. Determine distance and choose routing strategy
|
||||
final straightLineKm = _haversineKm(startNodePos, destNodePos);
|
||||
final isLongDistance = straightLineKm > 15.0; // >15km = hierarchical
|
||||
|
||||
// 3. Query subgraph with appropriate strategy
|
||||
final bboxMargin = isLongDistance ? 0.20 : 0.08;
|
||||
final localDelta = isLongDistance ? 0.08 : 0.0; // 8km around endpoints for local streets
|
||||
final minLat = math.min(start.latitude, destination.latitude) - bboxMargin;
|
||||
final maxLat = math.max(start.latitude, destination.latitude) + bboxMargin;
|
||||
final minLng = math.min(start.longitude, destination.longitude) - bboxMargin;
|
||||
final maxLng = math.max(start.longitude, destination.longitude) + bboxMargin;
|
||||
|
||||
List<Map<String, Object?>> rows;
|
||||
|
||||
if (isLongDistance) {
|
||||
// Hierarchical: major highways across entire bbox + local streets near start/end
|
||||
rows = await db.rawQuery('''
|
||||
SELECT from_node, to_node, name, highway, speed_kmh, length_m, geom_json
|
||||
FROM edges
|
||||
WHERE max_lat >= ? AND min_lat <= ? AND max_lng >= ? AND min_lng <= ?
|
||||
AND (
|
||||
highway IN ('motorway','motorway_link','trunk','trunk_link','primary','primary_link','secondary','secondary_link')
|
||||
OR (min_lat >= ? AND max_lat <= ? AND min_lng >= ? AND max_lng <= ?)
|
||||
OR (min_lat >= ? AND max_lat <= ? AND min_lng >= ? AND max_lng <= ?)
|
||||
)
|
||||
''', [
|
||||
minLat, maxLat, minLng, maxLng,
|
||||
start.latitude - localDelta, start.latitude + localDelta,
|
||||
start.longitude - localDelta, start.longitude + localDelta,
|
||||
destination.latitude - localDelta, destination.latitude + localDelta,
|
||||
destination.longitude - localDelta, destination.longitude + localDelta,
|
||||
]);
|
||||
} else {
|
||||
// Short distance: load ALL edges in bounding box
|
||||
rows = await db.rawQuery('''
|
||||
SELECT from_node, to_node, name, highway, speed_kmh, length_m, geom_json
|
||||
FROM edges
|
||||
WHERE max_lat >= ? AND min_lat <= ? AND max_lng >= ? AND min_lng <= ?
|
||||
''', [minLat, maxLat, minLng, maxLng]);
|
||||
}
|
||||
|
||||
if (rows.isEmpty) {
|
||||
debugPrint('OfflineRoadGraphEngine: No road edges found in bounding box');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Build in-memory adjacency list for fast A*
|
||||
final adjacency = <int, List<_GraphEdge>>{};
|
||||
final nodeIdsNeeded = <int>{};
|
||||
for (final r in rows) {
|
||||
final u = r['from_node'] as int;
|
||||
final v = r['to_node'] as int;
|
||||
final name = (r['name'] as String?) ?? '';
|
||||
final highway = (r['highway'] as String?) ?? 'unclassified';
|
||||
final speed = (r['speed_kmh'] as num?)?.toDouble() ?? 40.0;
|
||||
final lengthM = (r['length_m'] as num?)?.toDouble() ?? 100.0;
|
||||
|
||||
List<LatLng> coords = [];
|
||||
final geomStr = r['geom_json'] as String?;
|
||||
if (geomStr != null && geomStr.isNotEmpty) {
|
||||
try {
|
||||
final list = jsonDecode(geomStr) as List;
|
||||
coords = list.map((pt) => LatLng((pt[0] as num).toDouble(), (pt[1] as num).toDouble())).toList();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
adjacency.putIfAbsent(u, () => []).add(_GraphEdge(
|
||||
toNode: v,
|
||||
name: name,
|
||||
highway: highway,
|
||||
speedKmh: speed,
|
||||
lengthM: lengthM,
|
||||
coords: coords,
|
||||
));
|
||||
|
||||
nodeIdsNeeded.add(u);
|
||||
nodeIdsNeeded.add(v);
|
||||
}
|
||||
|
||||
// 5. Batch-query node positions for A* heuristic
|
||||
final nodePositions = <int, LatLng>{};
|
||||
final nodeIdList = nodeIdsNeeded.toList();
|
||||
for (int i = 0; i < nodeIdList.length; i += 500) {
|
||||
final chunk = nodeIdList.sublist(i, math.min(i + 500, nodeIdList.length));
|
||||
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||
final nodeRows = await db.rawQuery(
|
||||
'SELECT id, lat, lng FROM nodes WHERE id IN ($placeholders)',
|
||||
chunk,
|
||||
);
|
||||
for (final nr in nodeRows) {
|
||||
nodePositions[nr['id'] as int] = LatLng(
|
||||
(nr['lat'] as num).toDouble(),
|
||||
(nr['lng'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('OfflineRoadGraphEngine: Loaded ${rows.length} edges, ${nodePositions.length} nodes (${isLongDistance ? "hierarchical" : "local"} mode, ${straightLineKm.toStringAsFixed(1)}km straight-line)');
|
||||
|
||||
// 6. Run A* Search with priority queue
|
||||
final plan = _runAStarHeap(
|
||||
adjacency: adjacency,
|
||||
nodePositions: nodePositions,
|
||||
startNodeId: startNodeId,
|
||||
destNodeId: destNodeId,
|
||||
startUserPos: start,
|
||||
destUserPos: destination,
|
||||
startNodePos: startNodePos,
|
||||
destNodePos: destNodePos,
|
||||
profile: profile,
|
||||
);
|
||||
|
||||
stopwatch.stop();
|
||||
if (plan != null) {
|
||||
debugPrint('OfflineRoadGraphEngine: Route computed in ${stopwatch.elapsedMilliseconds}ms '
|
||||
'(${plan.totalDistanceKm}km, ${plan.polylinePoints.length} points, '
|
||||
'${plan.maneuvers.length} maneuvers)');
|
||||
} else {
|
||||
debugPrint('OfflineRoadGraphEngine: No route found after ${stopwatch.elapsedMilliseconds}ms');
|
||||
}
|
||||
return plan;
|
||||
} catch (e, st) {
|
||||
debugPrint('OfflineRoadGraphEngine: Route computation error: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap to nearest node that is an actual edge endpoint (not an orphan node).
|
||||
/// This is critical — the old method snapped to ANY node which often had no edges.
|
||||
static Future<Map<String, dynamic>?> _snapToConnectedNode(Database db, LatLng pos) async {
|
||||
// Search nearby connected nodes (edge endpoints) with spatial bounding box
|
||||
const delta = 0.02; // ~2km initial search radius
|
||||
var rows = await db.rawQuery('''
|
||||
SELECT DISTINCT e.from_node AS id, n.lat, n.lng,
|
||||
((n.lat - ?) * (n.lat - ?) + (n.lng - ?) * (n.lng - ?)) AS dist_sq
|
||||
FROM edges e
|
||||
JOIN nodes n ON e.from_node = n.id
|
||||
WHERE n.lat BETWEEN ? AND ? AND n.lng BETWEEN ? AND ?
|
||||
ORDER BY dist_sq ASC
|
||||
LIMIT 1;
|
||||
''', [
|
||||
pos.latitude, pos.latitude, pos.longitude, pos.longitude,
|
||||
pos.latitude - delta, pos.latitude + delta,
|
||||
pos.longitude - delta, pos.longitude + delta,
|
||||
]);
|
||||
|
||||
if (rows.isNotEmpty) return rows.first;
|
||||
|
||||
// Wider fallback: 10km radius
|
||||
const widerDelta = 0.1;
|
||||
rows = await db.rawQuery('''
|
||||
SELECT DISTINCT e.from_node AS id, n.lat, n.lng,
|
||||
((n.lat - ?) * (n.lat - ?) + (n.lng - ?) * (n.lng - ?)) AS dist_sq
|
||||
FROM edges e
|
||||
JOIN nodes n ON e.from_node = n.id
|
||||
WHERE n.lat BETWEEN ? AND ? AND n.lng BETWEEN ? AND ?
|
||||
ORDER BY dist_sq ASC
|
||||
LIMIT 1;
|
||||
''', [
|
||||
pos.latitude, pos.latitude, pos.longitude, pos.longitude,
|
||||
pos.latitude - widerDelta, pos.latitude + widerDelta,
|
||||
pos.longitude - widerDelta, pos.longitude + widerDelta,
|
||||
]);
|
||||
|
||||
return rows.isNotEmpty ? rows.first : null;
|
||||
}
|
||||
|
||||
/// A* with binary-heap priority queue for O(log n) extraction.
|
||||
/// The old implementation used a Set with linear scan — O(n) per step.
|
||||
static OfflineRoutePlan? _runAStarHeap({
|
||||
required Map<int, List<_GraphEdge>> adjacency,
|
||||
required Map<int, LatLng> nodePositions,
|
||||
required int startNodeId,
|
||||
required int destNodeId,
|
||||
required LatLng startUserPos,
|
||||
required LatLng destUserPos,
|
||||
required LatLng startNodePos,
|
||||
required LatLng destNodePos,
|
||||
required TacticalVehicleProfile profile,
|
||||
}) {
|
||||
if (startNodeId == destNodeId) {
|
||||
final dist = _haversineKm(startUserPos, destUserPos);
|
||||
return OfflineRoutePlan(
|
||||
polylinePoints: [startUserPos, startNodePos, destUserPos],
|
||||
totalDistanceKm: double.parse(dist.toStringAsFixed(1)),
|
||||
estimatedDurationMinutes: math.max(1.0, (dist / 40.0) * 60.0),
|
||||
profile: profile,
|
||||
tacticalWaypoints: const ['نقطة الانطلاق', 'نقطة الوصول'],
|
||||
usesRealRoadNetwork: true,
|
||||
isOffline: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority queue entries: [fScore, nodeId]
|
||||
// Using a list-based heap via SplayTreeMap for efficient min extraction
|
||||
final gScore = <int, double>{startNodeId: 0.0};
|
||||
final cameFrom = <int, _GraphEdge>{};
|
||||
final cameFromNode = <int, int>{};
|
||||
final visited = <int>{};
|
||||
|
||||
// Min-heap using a sorted structure: (fScore, nodeId)
|
||||
final pq = SplayTreeMap<double, List<int>>();
|
||||
final initialH = _haversineMeters(startNodePos, destNodePos);
|
||||
pq.putIfAbsent(initialH, () => []).add(startNodeId);
|
||||
|
||||
int explored = 0;
|
||||
const maxExplored = 200000; // Safety limit
|
||||
|
||||
while (pq.isNotEmpty && explored < maxExplored) {
|
||||
// Extract minimum fScore node
|
||||
final minEntry = pq.entries.first;
|
||||
final fVal = minEntry.key;
|
||||
final nodeList = minEntry.value;
|
||||
final current = nodeList.removeLast();
|
||||
if (nodeList.isEmpty) pq.remove(fVal);
|
||||
|
||||
if (visited.contains(current)) continue;
|
||||
visited.add(current);
|
||||
explored++;
|
||||
|
||||
if (current == destNodeId) {
|
||||
debugPrint('OfflineRoadGraphEngine: A* explored $explored nodes');
|
||||
return _reconstructPlan(
|
||||
cameFrom: cameFrom,
|
||||
cameFromNode: cameFromNode,
|
||||
destNodeId: destNodeId,
|
||||
startUserPos: startUserPos,
|
||||
destUserPos: destUserPos,
|
||||
profile: profile,
|
||||
);
|
||||
}
|
||||
|
||||
final currentG = gScore[current] ?? double.infinity;
|
||||
final neighbors = adjacency[current] ?? [];
|
||||
|
||||
for (final edge in neighbors) {
|
||||
final neighbor = edge.toNode;
|
||||
if (visited.contains(neighbor)) continue;
|
||||
|
||||
final tentativeG = currentG + edge.lengthM;
|
||||
|
||||
if (tentativeG < (gScore[neighbor] ?? double.infinity)) {
|
||||
cameFrom[neighbor] = edge;
|
||||
cameFromNode[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
|
||||
final neighborPos = nodePositions[neighbor] ?? destNodePos;
|
||||
final h = _haversineMeters(neighborPos, destNodePos);
|
||||
final f = tentativeG + h;
|
||||
pq.putIfAbsent(f, () => []).add(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('OfflineRoadGraphEngine: A* exhausted after $explored nodes');
|
||||
return null;
|
||||
}
|
||||
|
||||
static OfflineRoutePlan _reconstructPlan({
|
||||
required Map<int, _GraphEdge> cameFrom,
|
||||
required Map<int, int> cameFromNode,
|
||||
required int destNodeId,
|
||||
required LatLng startUserPos,
|
||||
required LatLng destUserPos,
|
||||
required TacticalVehicleProfile profile,
|
||||
}) {
|
||||
final polyline = <LatLng>[];
|
||||
final maneuvers = <RouteManeuver>[];
|
||||
final waypoints = <String>[];
|
||||
double totalMeters = 0.0;
|
||||
double totalSeconds = 0.0;
|
||||
|
||||
int curr = destNodeId;
|
||||
final edgeChain = <_GraphEdge>[];
|
||||
|
||||
while (cameFrom.containsKey(curr)) {
|
||||
final edge = cameFrom[curr]!;
|
||||
edgeChain.add(edge);
|
||||
curr = cameFromNode[curr]!;
|
||||
}
|
||||
|
||||
final forwardEdges = edgeChain.reversed.toList();
|
||||
polyline.add(startUserPos);
|
||||
|
||||
// Apply tactical profile speed multiplier
|
||||
double profileFactor;
|
||||
switch (profile) {
|
||||
case TacticalVehicleProfile.convoy:
|
||||
profileFactor = 1.45;
|
||||
break;
|
||||
case TacticalVehicleProfile.armored:
|
||||
profileFactor = 1.75;
|
||||
break;
|
||||
case TacticalVehicleProfile.offroad4x4:
|
||||
profileFactor = 1.1;
|
||||
break;
|
||||
case TacticalVehicleProfile.rapidResponse:
|
||||
profileFactor = 0.85;
|
||||
break;
|
||||
}
|
||||
|
||||
String lastStreetName = '';
|
||||
double segmentLengthM = 0.0;
|
||||
|
||||
for (final edge in forwardEdges) {
|
||||
totalMeters += edge.lengthM;
|
||||
final speedMs = (edge.speedKmh * 1000.0) / 3600.0;
|
||||
totalSeconds += (edge.lengthM / (speedMs > 0 ? speedMs : 10.0)) * profileFactor;
|
||||
|
||||
if (edge.coords.isNotEmpty) {
|
||||
polyline.addAll(edge.coords);
|
||||
}
|
||||
|
||||
if (edge.name.isNotEmpty && edge.name != lastStreetName) {
|
||||
// Generate a maneuver for the street change
|
||||
if (lastStreetName.isNotEmpty && segmentLengthM > 0) {
|
||||
// Determine turn direction heuristic from polyline geometry
|
||||
final turnType = _detectTurnType(polyline);
|
||||
final turnInstr = _arabicTurnInstruction(turnType, edge.name);
|
||||
if (edge.coords.isNotEmpty) {
|
||||
maneuvers.add(RouteManeuver(
|
||||
type: turnType,
|
||||
instructionAr: turnInstr,
|
||||
streetNames: [edge.name],
|
||||
lengthKm: edge.lengthM / 1000.0,
|
||||
location: edge.coords.first,
|
||||
));
|
||||
}
|
||||
} else if (edge.coords.isNotEmpty) {
|
||||
// First street segment
|
||||
maneuvers.add(RouteManeuver(
|
||||
type: 0, // depart
|
||||
instructionAr: 'انطلق على ${edge.name}',
|
||||
streetNames: [edge.name],
|
||||
lengthKm: edge.lengthM / 1000.0,
|
||||
location: edge.coords.first,
|
||||
));
|
||||
}
|
||||
lastStreetName = edge.name;
|
||||
waypoints.add(edge.name);
|
||||
segmentLengthM = edge.lengthM;
|
||||
} else {
|
||||
segmentLengthM += edge.lengthM;
|
||||
}
|
||||
}
|
||||
|
||||
// Final arrival maneuver
|
||||
maneuvers.add(RouteManeuver(
|
||||
type: 4, // arrive
|
||||
instructionAr: 'وصلت إلى وجهتك',
|
||||
streetNames: const [],
|
||||
lengthKm: 0.0,
|
||||
location: destUserPos,
|
||||
));
|
||||
|
||||
polyline.add(destUserPos);
|
||||
|
||||
final totalKm = totalMeters / 1000.0;
|
||||
final durationMin = math.max(1.0, totalSeconds / 60.0);
|
||||
|
||||
return OfflineRoutePlan(
|
||||
polylinePoints: polyline,
|
||||
totalDistanceKm: double.parse(totalKm.toStringAsFixed(1)),
|
||||
estimatedDurationMinutes: double.parse(durationMin.toStringAsFixed(0)),
|
||||
profile: profile,
|
||||
tacticalWaypoints: waypoints.take(8).toList(),
|
||||
usesRealRoadNetwork: true,
|
||||
isOffline: true,
|
||||
maneuvers: maneuvers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple turn type detection from the last 3 polyline points
|
||||
static int _detectTurnType(List<LatLng> polyline) {
|
||||
if (polyline.length < 3) return 1; // continue
|
||||
final p1 = polyline[polyline.length - 3];
|
||||
final p2 = polyline[polyline.length - 2];
|
||||
final p3 = polyline[polyline.length - 1];
|
||||
|
||||
final bearing1 = math.atan2(p2.longitude - p1.longitude, p2.latitude - p1.latitude);
|
||||
final bearing2 = math.atan2(p3.longitude - p2.longitude, p3.latitude - p2.latitude);
|
||||
var angleDiff = (bearing2 - bearing1) * 180.0 / math.pi;
|
||||
while (angleDiff > 180) {
|
||||
angleDiff -= 360;
|
||||
}
|
||||
while (angleDiff < -180) {
|
||||
angleDiff += 360;
|
||||
}
|
||||
|
||||
if (angleDiff.abs() < 20) return 1; // continue straight
|
||||
if (angleDiff > 20 && angleDiff < 160) return 3; // turn right
|
||||
if (angleDiff < -20 && angleDiff > -160) return 2; // turn left
|
||||
return 1; // continue
|
||||
}
|
||||
|
||||
/// Generate Arabic turn-by-turn instruction
|
||||
static String _arabicTurnInstruction(int turnType, String streetName) {
|
||||
switch (turnType) {
|
||||
case 0:
|
||||
return 'انطلق على $streetName';
|
||||
case 2:
|
||||
return 'انعطف يساراً إلى $streetName';
|
||||
case 3:
|
||||
return 'انعطف يميناً إلى $streetName';
|
||||
case 4:
|
||||
return 'وصلت إلى وجهتك';
|
||||
default:
|
||||
return 'تابع السير على $streetName';
|
||||
}
|
||||
}
|
||||
|
||||
static double _haversineMeters(LatLng p1, LatLng p2) {
|
||||
return _haversineKm(p1, p2) * 1000.0;
|
||||
}
|
||||
|
||||
static double _haversineKm(LatLng p1, LatLng p2) {
|
||||
const R = 6371.0;
|
||||
final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0);
|
||||
final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0);
|
||||
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(p1.latitude * (math.pi / 180.0)) *
|
||||
math.cos(p2.latitude * (math.pi / 180.0)) *
|
||||
math.sin(dLon / 2) *
|
||||
math.sin(dLon / 2);
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,26 @@ class RoadEdge {
|
||||
});
|
||||
}
|
||||
|
||||
/// مناورة حقيقية من محرك التوجيه (Valhalla) مع تعليمات عربية وأسماء شوارع فعلية
|
||||
class RouteManeuver {
|
||||
/// Numeric Valhalla maneuver type (see valhalla ManeuverType enum)
|
||||
final int type;
|
||||
final String instructionAr;
|
||||
final List<String> streetNames;
|
||||
final double lengthKm;
|
||||
final LatLng location;
|
||||
|
||||
const RouteManeuver({
|
||||
required this.type,
|
||||
required this.instructionAr,
|
||||
required this.streetNames,
|
||||
required this.lengthKm,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
bool get hasStreetName => streetNames.isNotEmpty;
|
||||
}
|
||||
|
||||
class OfflineRoutePlan {
|
||||
final List<LatLng> polylinePoints;
|
||||
final double totalDistanceKm;
|
||||
@@ -56,6 +76,13 @@ class OfflineRoutePlan {
|
||||
final double averageInclinePercent;
|
||||
final double maxInclinePercent;
|
||||
|
||||
/// True when the route follows the real downloaded road network
|
||||
/// (on-device Valhalla graph) instead of a synthetic fallback path.
|
||||
final bool usesRealRoadNetwork;
|
||||
|
||||
/// Real turn-by-turn maneuvers from the routing engine (empty on fallbacks).
|
||||
final List<RouteManeuver> maneuvers;
|
||||
|
||||
const OfflineRoutePlan({
|
||||
required this.polylinePoints,
|
||||
required this.totalDistanceKm,
|
||||
@@ -67,6 +94,8 @@ class OfflineRoutePlan {
|
||||
this.elevationLossMeters = 0,
|
||||
this.averageInclinePercent = 0.0,
|
||||
this.maxInclinePercent = 0.0,
|
||||
this.usesRealRoadNetwork = false,
|
||||
this.maneuvers = const [],
|
||||
});
|
||||
|
||||
String get inclineSummaryArabic {
|
||||
@@ -91,22 +120,37 @@ class OfflineRoutingEngine {
|
||||
|
||||
// ── Strategic Road Network Nodes Across Jordan with Elevation (DEM) ──
|
||||
final nodeList = const [
|
||||
// Amman Urban Hubs & Rings
|
||||
// ── Amman Urban Hubs & Arterial Corridors ──────────────────
|
||||
RoadNode(id: 'amm_center', name: 'وسط عمان / العبدلي', lat: 31.9615, lng: 35.9130, elevationM: 760),
|
||||
RoadNode(id: 'amm_citadel', name: 'جبل القلعة / المدرج الروماني', lat: 31.9540, lng: 35.9350, elevationM: 850),
|
||||
RoadNode(id: 'amm_1st', name: 'الدوار الأول / جبل عمان', lat: 31.9510, lng: 35.9220, elevationM: 850),
|
||||
RoadNode(id: 'amm_2nd', name: 'الدوار الثاني / جبل عمان', lat: 31.9525, lng: 35.9120, elevationM: 870),
|
||||
RoadNode(id: 'amm_3rd', name: 'الدوار الثالث / زهران', lat: 31.9545, lng: 35.9015, elevationM: 890),
|
||||
RoadNode(id: 'amm_4th', name: 'الدوار الرابع / رئاسة الوزراء', lat: 31.9560, lng: 35.8910, elevationM: 900),
|
||||
RoadNode(id: 'amm_5th', name: 'الدوار الخامس / وادي صقرة', lat: 31.9570, lng: 35.8810, elevationM: 910),
|
||||
RoadNode(id: 'amm_6th', name: 'الدوار السادس / أبراج بوابة الأردن', lat: 31.9580, lng: 35.8680, elevationM: 930),
|
||||
RoadNode(id: 'amm_7th', name: 'الدوار السابع / طريق المطار', lat: 31.9530, lng: 35.8580, elevationM: 920),
|
||||
RoadNode(id: 'amm_8th', name: 'الدوار الثامن / وادي السير', lat: 31.9515, lng: 35.8350, elevationM: 940),
|
||||
RoadNode(id: 'amm_dabouq', name: 'دابوق / قاعدة القيادة الغربية', lat: 31.9960, lng: 35.8285, elevationM: 1045),
|
||||
RoadNode(id: 'amm_sweileh', name: 'صويلح / تقاطع الشمال', lat: 32.0220, lng: 35.8450, elevationM: 1030),
|
||||
RoadNode(id: 'amm_abdoun', name: 'عبدون / جسر عبدون المعلق', lat: 31.9420, lng: 35.8910, elevationM: 860),
|
||||
RoadNode(id: 'amm_shmeisani', name: 'الشميساني / مجمع النقابات والبنك المركزي', lat: 31.9680, lng: 35.9010, elevationM: 890),
|
||||
RoadNode(id: 'amm_sports_city', name: 'دوار المدينة الرياضية / صرح الشهيد', lat: 31.9850, lng: 35.8980, elevationM: 920),
|
||||
RoadNode(id: 'amm_gardens', name: 'شارع وصفي التل (الجاردنز) / الواحة', lat: 31.9820, lng: 35.8750, elevationM: 950),
|
||||
RoadNode(id: 'amm_medina', name: 'شارع المدينة المنورة / مستشفى ابن الهيثم', lat: 31.9910, lng: 35.8720, elevationM: 970),
|
||||
RoadNode(id: 'amm_mecca', name: 'شارع مكة / مجمع الحرمين', lat: 31.9690, lng: 35.8550, elevationM: 960),
|
||||
RoadNode(id: 'amm_khalda', name: 'خلدا / دوار الواحة وصدا', lat: 31.9890, lng: 35.8580, elevationM: 990),
|
||||
RoadNode(id: 'amm_tlaa', name: 'طلاء العلي / سوق السلطان', lat: 31.9980, lng: 35.8650, elevationM: 1010),
|
||||
RoadNode(id: 'amm_univ', name: 'الجامعة الأردنية / البوابة الرئيسية', lat: 32.0120, lng: 35.8710, elevationM: 1005),
|
||||
RoadNode(id: 'amm_sweileh', name: 'صويلح / تقاطع الشمال', lat: 32.0220, lng: 35.8450, elevationM: 1030),
|
||||
RoadNode(id: 'amm_jubaiha', name: 'الجبيهة / الدوريات الخارجية والتعليم العالي', lat: 32.0280, lng: 35.8620, elevationM: 1040),
|
||||
RoadNode(id: 'amm_dabouq', name: 'دابوق / قاعدة القيادة الغربية', lat: 31.9960, lng: 35.8285, elevationM: 1045),
|
||||
RoadNode(id: 'amm_tabarbour', name: 'طبربور / تقاطع المشاغل والزرقاء', lat: 31.9920, lng: 35.9450, elevationM: 880),
|
||||
RoadNode(id: 'amm_jordan_st', name: 'شارع الأردن / تقاطع ياجوز', lat: 32.0150, lng: 35.9220, elevationM: 930),
|
||||
RoadNode(id: 'amm_marka', name: 'ماركا / مطار عمان المدني', lat: 31.9720, lng: 35.9910, elevationM: 770),
|
||||
RoadNode(id: 'amm_sahab', name: 'سحاب / مدينة الملك عبدالله الثاني الصناعية', lat: 31.8710, lng: 36.0040, elevationM: 820),
|
||||
RoadNode(id: 'amm_marj', name: 'مرج الحمام / تقاطع ناعور والمطار', lat: 31.8950, lng: 35.8520, elevationM: 910),
|
||||
RoadNode(id: 'amm_airport', name: 'مطار الملكة علياء الدولي (الجيزة)', lat: 31.7200, lng: 35.9880, elevationM: 720),
|
||||
|
||||
// Desert Highway Corridor (Route 15 - الطريق الصحراوي الرئيسي)
|
||||
// ── Desert Highway Corridor (Route 15) ───────────────────────
|
||||
RoadNode(id: 'dabaa', name: 'محطة ضبعة / الطريق الصحراوي', lat: 31.5420, lng: 36.0150, elevationM: 700),
|
||||
RoadNode(id: 'qatrana', name: 'القطرانة / جسر القطرانة العسكري', lat: 31.2450, lng: 36.0420, elevationM: 790),
|
||||
RoadNode(id: 'sultani', name: 'محطة السلطاني / سد السلطاني', lat: 31.0520, lng: 35.9980, elevationM: 820),
|
||||
@@ -118,7 +162,7 @@ class OfflineRoutingEngine {
|
||||
RoadNode(id: 'quwayra', name: 'القويرة / مدخل وادي رم', lat: 29.8050, lng: 35.3120, elevationM: 800),
|
||||
RoadNode(id: 'aqaba_port', name: 'ميناء العقبة الجنوبي / الساحل', lat: 29.4120, lng: 34.9810, elevationM: 15),
|
||||
|
||||
// King's Highway Corridor (Route 35 - الطريق الملوكي الجبلي)
|
||||
// ── King's Highway Corridor (Route 35) ───────────────────────
|
||||
RoadNode(id: 'madaba_nebo', name: 'مادبا / جبل نيبو', lat: 31.7450, lng: 35.7750, elevationM: 780),
|
||||
RoadNode(id: 'dhiban_mujib', name: 'ذيبان / وادي الموجب الشاهق', lat: 31.5020, lng: 35.7820, elevationM: 720),
|
||||
RoadNode(id: 'qasr_karak', name: 'القصر / شمال الكرك', lat: 31.3150, lng: 35.7480, elevationM: 920),
|
||||
@@ -129,21 +173,21 @@ class OfflineRoutingEngine {
|
||||
RoadNode(id: 'petra_wadi_musa', name: 'البتراء / وادي موسى وجبل هارون', lat: 30.3200, lng: 35.4750, elevationM: 1150),
|
||||
RoadNode(id: 'wadi_rum', name: 'محمية وادي رم / رم والديسة', lat: 29.5740, lng: 35.4190, elevationM: 950),
|
||||
|
||||
// Dead Sea & Jordan Valley Corridor (Route 65 - طريق الأغوار والبحر الميت)
|
||||
// ── Dead Sea & Jordan Valley Corridor (Route 65) ─────────────
|
||||
RoadNode(id: 'dead_sea_north', name: 'شمال البحر الميت / السويمة', lat: 31.7200, lng: 35.5800, elevationM: -390),
|
||||
RoadNode(id: 'zara_hotsprings', name: 'منطقة الزارة / شاطئ البحر الميت', lat: 31.5950, lng: 35.5620, elevationM: -420),
|
||||
RoadNode(id: 'ghor_safi', name: 'غور الصافي / مصانع البوتاس', lat: 31.0350, lng: 35.4850, elevationM: -385),
|
||||
RoadNode(id: 'feifa_araba', name: 'فيفا / وادي عربة الأوسط', lat: 30.7250, lng: 35.3950, elevationM: -180),
|
||||
RoadNode(id: 'rahma_border', name: 'الرحمة / طريق وادي عربة الجنوبي', lat: 29.9150, lng: 35.1520, elevationM: 90),
|
||||
|
||||
// Zarqa & Eastern Desert Corridor (Route 30 - طريق الأزرق وبغداد الدولي)
|
||||
// ── Zarqa & Eastern Desert Corridor (Route 30) ───────────────
|
||||
RoadNode(id: 'zrq_city', name: 'الزرقاء / الهاشمية ومعسكرات الجيش', lat: 32.0720, lng: 36.0880, elevationM: 610),
|
||||
RoadNode(id: 'azraq_junction', name: 'تقاطع مثلث الأزرق / واحة الأزرق', lat: 31.8380, lng: 36.8120, elevationM: 520),
|
||||
RoadNode(id: 'azraq_airbase', name: 'قاعدة الشهيد موفق السلطي الجوية', lat: 31.8320, lng: 36.7860, elevationM: 515),
|
||||
RoadNode(id: 'safawi', name: 'الصفاوي / تقاطع طريق بغداد', lat: 32.2010, lng: 37.1230, elevationM: 680),
|
||||
RoadNode(id: 'ruwaished', name: 'الرويشد / طريبيل والحدود الشرقية', lat: 32.5020, lng: 38.2040, elevationM: 700),
|
||||
|
||||
// North Strategic Corridor (Jerash, Ajloun, Irbid, Mafraq)
|
||||
// ── North Strategic Corridor ─────────────────────────────────
|
||||
RoadNode(id: 'salt_city', name: 'السلط / جبال البلقاء', lat: 32.0390, lng: 35.7280, elevationM: 850),
|
||||
RoadNode(id: 'jerash', name: 'جرش / جسر سيل جرش', lat: 32.2780, lng: 35.8950, elevationM: 580),
|
||||
RoadNode(id: 'ajloun', name: 'عجلون / قلعة الربض وغابات عجلون', lat: 32.3250, lng: 35.7350, elevationM: 1100),
|
||||
@@ -167,7 +211,6 @@ class OfflineRoutingEngine {
|
||||
|
||||
final dense = coords ?? _generateDenseRoadCurve(p1, p2, distKm);
|
||||
|
||||
// Incline % = (Elevation Change in meters / Distance in meters) * 100
|
||||
final elevDiffM = n2.elevationM - n1.elevationM;
|
||||
final distM = distKm * 1000.0;
|
||||
final incline = (elevDiffM / (distM > 0 ? distM : 1000.0)) * 100.0;
|
||||
@@ -193,23 +236,65 @@ class OfflineRoutingEngine {
|
||||
));
|
||||
}
|
||||
|
||||
// 1. Amman Ring & Urban Arterials
|
||||
// 1. Amman Inner Grid & Zahran Spine (1st Circle to 8th Circle)
|
||||
addEdge('amm_center', 'amm_citadel', 1.2, 'primary', 45.0);
|
||||
addEdge('amm_center', 'amm_1st', 1.5, 'primary', 50.0);
|
||||
addEdge('amm_1st', 'amm_2nd', 1.0, 'primary', 50.0);
|
||||
addEdge('amm_2nd', 'amm_3rd', 1.1, 'primary', 55.0);
|
||||
addEdge('amm_3rd', 'amm_4th', 1.0, 'primary', 55.0);
|
||||
addEdge('amm_4th', 'amm_5th', 1.2, 'primary', 60.0);
|
||||
addEdge('amm_5th', 'amm_6th', 1.3, 'primary', 60.0);
|
||||
addEdge('amm_6th', 'amm_7th', 1.2, 'primary', 65.0);
|
||||
addEdge('amm_7th', 'amm_8th', 2.2, 'primary', 65.0);
|
||||
|
||||
// 2. Abdoun & Shmeisani Links
|
||||
addEdge('amm_4th', 'amm_abdoun', 2.0, 'primary', 60.0);
|
||||
addEdge('amm_5th', 'amm_abdoun', 2.2, 'primary', 60.0);
|
||||
addEdge('amm_7th', 'amm_abdoun', 3.8, 'primary', 70.0);
|
||||
addEdge('amm_center', 'amm_shmeisani', 2.5, 'primary', 55.0);
|
||||
addEdge('amm_3rd', 'amm_shmeisani', 2.0, 'primary', 55.0);
|
||||
addEdge('amm_4th', 'amm_shmeisani', 1.8, 'primary', 55.0);
|
||||
addEdge('amm_shmeisani', 'amm_sports_city', 2.4, 'primary', 65.0);
|
||||
|
||||
// 3. Mecca St & Medina St & Gardens St Corridors
|
||||
addEdge('amm_5th', 'amm_gardens', 2.8, 'primary', 60.0);
|
||||
addEdge('amm_sports_city', 'amm_gardens', 2.5, 'primary', 60.0);
|
||||
addEdge('amm_gardens', 'amm_medina', 1.8, 'primary', 60.0);
|
||||
addEdge('amm_gardens', 'amm_khalda', 2.2, 'primary', 60.0);
|
||||
addEdge('amm_mecca', 'amm_medina', 2.2, 'primary', 65.0);
|
||||
addEdge('amm_mecca', 'amm_khalda', 2.5, 'primary', 65.0);
|
||||
addEdge('amm_mecca', 'amm_8th', 3.0, 'primary', 65.0);
|
||||
addEdge('amm_6th', 'amm_mecca', 1.5, 'primary', 60.0);
|
||||
|
||||
// 4. University St & Sweileh & Tlaa Al-Ali
|
||||
addEdge('amm_sports_city', 'amm_univ', 3.2, 'primary', 65.0);
|
||||
addEdge('amm_medina', 'amm_univ', 2.6, 'primary', 65.0);
|
||||
addEdge('amm_khalda', 'amm_tlaa', 1.5, 'primary', 60.0);
|
||||
addEdge('amm_tlaa', 'amm_univ', 1.8, 'primary', 60.0);
|
||||
addEdge('amm_univ', 'amm_jubaiha', 2.0, 'primary', 65.0);
|
||||
addEdge('amm_univ', 'amm_sweileh', 3.0, 'primary', 70.0);
|
||||
addEdge('amm_tlaa', 'amm_sweileh', 3.2, 'primary', 65.0);
|
||||
addEdge('amm_khalda', 'amm_dabouq', 3.5, 'primary', 65.0);
|
||||
addEdge('amm_dabouq', 'amm_sweileh', 4.5, 'primary', 70.0);
|
||||
addEdge('amm_sweileh', 'amm_khalda', 5.0, 'primary', 65.0);
|
||||
addEdge('amm_khalda', 'amm_8th', 6.5, 'primary', 65.0);
|
||||
addEdge('amm_8th', 'amm_7th', 2.8, 'primary', 65.0);
|
||||
addEdge('amm_7th', 'amm_5th', 3.2, 'primary', 65.0);
|
||||
addEdge('amm_5th', 'amm_3rd', 2.5, 'primary', 60.0);
|
||||
addEdge('amm_3rd', 'amm_1st', 2.2, 'primary', 55.0);
|
||||
addEdge('amm_1st', 'amm_center', 2.0, 'primary', 50.0);
|
||||
addEdge('amm_dabouq', 'amm_center', 11.0, 'primary', 65.0);
|
||||
addEdge('amm_sweileh', 'amm_jubaiha', 2.5, 'primary', 65.0);
|
||||
|
||||
// 5. Jordan St & Tabarbour & Marka
|
||||
addEdge('amm_center', 'amm_tabarbour', 9.5, 'primary', 65.0);
|
||||
addEdge('amm_sports_city', 'amm_jordan_st', 3.5, 'highway', 80.0);
|
||||
addEdge('amm_jordan_st', 'amm_jubaiha', 4.0, 'highway', 80.0);
|
||||
addEdge('amm_tabarbour', 'amm_jordan_st', 3.2, 'primary', 65.0);
|
||||
addEdge('amm_tabarbour', 'zrq_city', 18.0, 'highway', 85.0);
|
||||
addEdge('amm_tabarbour', 'amm_marka', 6.5, 'primary', 60.0);
|
||||
addEdge('amm_marka', 'amm_sahab', 14.0, 'primary', 75.0);
|
||||
|
||||
// 6. Airport Highway & Marj Al-Hamam
|
||||
addEdge('amm_7th', 'amm_marj', 6.5, 'highway', 90.0);
|
||||
addEdge('amm_8th', 'amm_marj', 7.0, 'primary', 75.0);
|
||||
addEdge('amm_marj', 'amm_airport', 22.0, 'highway', 100.0);
|
||||
addEdge('amm_7th', 'amm_airport', 28.0, 'highway', 100.0);
|
||||
addEdge('amm_sahab', 'amm_airport', 24.0, 'highway', 95.0);
|
||||
addEdge('amm_dabouq', 'salt_city', 16.0, 'primary', 65.0);
|
||||
addEdge('amm_marj', 'madaba_nebo', 22.0, 'primary', 75.0);
|
||||
addEdge('amm_7th', 'madaba_nebo', 26.0, 'primary', 75.0);
|
||||
addEdge('salt_city', 'dead_sea_north', 32.0, 'secondary', 55.0);
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
enum RoutingPackageStatus {
|
||||
notInstalled,
|
||||
downloading,
|
||||
extracting,
|
||||
installed,
|
||||
error,
|
||||
}
|
||||
|
||||
/// [OfflineRoutingPackageService] - خدمة تنزيل وتثبيت حزمة التوجيه المحلية
|
||||
///
|
||||
/// English:
|
||||
/// Downloads the Jordan Valhalla routing tar (real road graph + SRTM elevation)
|
||||
/// from the sovereign server, verifies its SHA-256, extracts valhalla_tiles.tar
|
||||
/// (+ admins.sqlite) into the app documents dir and keeps install state.
|
||||
///
|
||||
/// العربية:
|
||||
/// تُنزّل حزمة توجيه الأردن (غراف الطرق الحقيقي + ارتفاعات SRTM) من السيرفر،
|
||||
/// تتحقق من بصمة SHA-256، تفكّ الأرشيف في مجلد التطبيق، وتتتبع حالة التثبيت —
|
||||
/// لتعمل الملاحة بعدها 100% بدون إنترنت على الشوارع الرسمية.
|
||||
class OfflineRoutingPackageService {
|
||||
static const String _versionKey = 'routing_pkg_version';
|
||||
static const String _dateKey = 'routing_pkg_date';
|
||||
static const String _dirKey = 'routing_pkg_dir';
|
||||
|
||||
static String get defaultServerUrl => AppConfig.serverUrl;
|
||||
static String get defaultApiKey => AppConfig.apiKey;
|
||||
|
||||
static final ValueNotifier<double> downloadProgress = ValueNotifier(0.0);
|
||||
static final ValueNotifier<String> statusMessage = ValueNotifier('حزمة التوجيه غير مثبتة');
|
||||
static final ValueNotifier<RoutingPackageStatus> status =
|
||||
ValueNotifier(RoutingPackageStatus.notInstalled);
|
||||
|
||||
/// مجلد تثبيت الحزمة على الجهاز (يقرأه ValhallaOfflineEngine)
|
||||
static Future<Directory> installDir() async {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
return Directory('${docs.path}/routing/jordan');
|
||||
}
|
||||
|
||||
/// المسار النصي لمجلد الحزمة (يُمرَّر للمحرك عبر MethodChannel)
|
||||
static Future<String> installDirPath() async => (await installDir()).path;
|
||||
|
||||
/// هل الحزمة مثبتة فعلياً (ملف الغراف موجود على القرص)؟
|
||||
static Future<bool> isInstalled() async {
|
||||
try {
|
||||
final dir = await installDir();
|
||||
if (!dir.existsSync()) return false;
|
||||
return _findFile(dir, 'valhalla_tiles.tar') != null ||
|
||||
_findFile(dir, 'jordan_roads.db') != null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// إصدار الحزمة المثبتة حالياً
|
||||
static Future<String?> installedVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_versionKey);
|
||||
}
|
||||
|
||||
/// جلب بيان الحزمة من السيرفر (إصدار/حجم/بصمة)
|
||||
static Future<Map<String, dynamic>?> fetchManifest({String? serverUrl, String? apiKey}) async {
|
||||
try {
|
||||
final uri = Uri.parse('${serverUrl ?? defaultServerUrl}/api/tactical/routing-package/jordan/manifest');
|
||||
final resp = await http.get(uri, headers: {'x-api-key': apiKey ?? defaultApiKey})
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (resp.statusCode == 200) {
|
||||
final data = jsonDecodeMap(resp.body);
|
||||
if (data != null && data['available'] == true) return data;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Routing manifest fetch error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// محاولة تثبيت تلقائي واحد لكل جلسة — يستدعيها محرك الملاحة عند الحاجة.
|
||||
///
|
||||
/// English:
|
||||
/// Ensures the routing package exists on device. If missing, attempts ONE
|
||||
/// silent download per session (fails fast offline). Returns true when the
|
||||
/// package is available afterwards.
|
||||
static bool _autoInstallAttemptedThisSession = false;
|
||||
|
||||
static Future<bool> ensureInstalled({
|
||||
String? serverUrl,
|
||||
String? apiKey,
|
||||
}) async {
|
||||
if (await isInstalled()) return true;
|
||||
if (_autoInstallAttemptedThisSession) return false;
|
||||
_autoInstallAttemptedThisSession = true;
|
||||
|
||||
debugPrint('RoutingPackage: not installed — attempting one-time auto download...');
|
||||
return downloadAndInstall(serverUrl: serverUrl, apiKey: apiKey);
|
||||
}
|
||||
|
||||
/// إعادة تفعيل المحاولة التلقائية (بعد مزامنة يدوية ناجحة مثلاً)
|
||||
static void resetAutoInstallAttempt() => _autoInstallAttemptedThisSession = false;
|
||||
|
||||
/// التنزيل الكامل: تحقق ← تنزيل مع تقدّم ← تحقق SHA-256 ← فك أرشيف ← تثبيت
|
||||
static Future<bool> downloadAndInstall({
|
||||
String? serverUrl,
|
||||
String? apiKey,
|
||||
Map<String, dynamic>? manifestOverride,
|
||||
}) async {
|
||||
try {
|
||||
status.value = RoutingPackageStatus.downloading;
|
||||
downloadProgress.value = 0.0;
|
||||
statusMessage.value = 'جاري التحقق من حزمة التوجيه على السيرفر...';
|
||||
|
||||
final manifest = manifestOverride ??
|
||||
await fetchManifest(serverUrl: serverUrl, apiKey: apiKey);
|
||||
if (manifest == null) {
|
||||
status.value = RoutingPackageStatus.error;
|
||||
statusMessage.value = 'حزمة التوجيه غير متوفرة على السيرفر بعد.';
|
||||
return false;
|
||||
}
|
||||
|
||||
final expectedSha256 = (manifest['sha256'] as String?)?.toLowerCase();
|
||||
final version = (manifest['version'] as String?) ?? 'unknown';
|
||||
final totalBytes = (manifest['sizeBytes'] as num?)?.toInt() ?? 0;
|
||||
|
||||
// 1. Streaming download with progress
|
||||
statusMessage.value = 'جاري تنزيل غراف طرق الأردن (${_formatBytes(totalBytes)})...';
|
||||
final uri = Uri.parse('${serverUrl ?? defaultServerUrl}/api/tactical/routing-package/jordan');
|
||||
final client = http.Client();
|
||||
final request = http.Request('GET', uri)..headers['x-api-key'] = apiKey ?? defaultApiKey;
|
||||
final response = await client.send(request).timeout(const Duration(minutes: 30));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
status.value = RoutingPackageStatus.error;
|
||||
statusMessage.value = 'فشل تنزيل الحزمة (HTTP ${response.statusCode}).';
|
||||
return false;
|
||||
}
|
||||
|
||||
final contentLength = response.contentLength ?? totalBytes;
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final tmpFile = File('${tmpDir.path}/jordan_routing_package.tar');
|
||||
final sink = tmpFile.openWrite();
|
||||
|
||||
int received = 0;
|
||||
await for (final chunk in response.stream) {
|
||||
received += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = (received / contentLength) * 0.85; // 0-85%
|
||||
}
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
client.close();
|
||||
|
||||
// 2. Integrity check
|
||||
statusMessage.value = 'جاري التحقق من سلامة الحزمة (SHA-256)...';
|
||||
final actualSha256 = (await _sha256OfFile(tmpFile)).toLowerCase();
|
||||
if (expectedSha256 != null && actualSha256 != expectedSha256) {
|
||||
await tmpFile.delete();
|
||||
status.value = RoutingPackageStatus.error;
|
||||
statusMessage.value = 'بصمة الحزمة غير مطابقة — يوجد تلف في التنزيل. أعد المحاولة.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Extract into install dir
|
||||
status.value = RoutingPackageStatus.extracting;
|
||||
downloadProgress.value = 0.9;
|
||||
statusMessage.value = 'جاري فك وتثبيت غراف التوجيه على الجهاز...';
|
||||
|
||||
final dir = await installDir();
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
|
||||
final extracted = await _extractTar(tmpFile, dir);
|
||||
await tmpFile.delete();
|
||||
|
||||
if (!extracted) {
|
||||
status.value = RoutingPackageStatus.error;
|
||||
statusMessage.value = 'تعذر فك أرشيف الحزمة.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Persist install state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_versionKey, version);
|
||||
await prefs.setString(_dateKey, DateTime.now().toIso8601String());
|
||||
await prefs.setString(_dirKey, dir.path);
|
||||
|
||||
downloadProgress.value = 1.0;
|
||||
status.value = RoutingPackageStatus.installed;
|
||||
statusMessage.value = 'تم تثبيت شبكة الطرق الحقيقية للأردن! التوجيه يعمل الآن بدون إنترنت.';
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Routing package install error: $e');
|
||||
status.value = RoutingPackageStatus.error;
|
||||
statusMessage.value = 'خطأ في تثبيت حزمة التوجيه: $e';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
static Map<String, dynamic>? jsonDecodeMap(String body) {
|
||||
try {
|
||||
final value = jsonDecode(body);
|
||||
return value is Map<String, dynamic> ? value : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static File? _findFile(Directory dir, String name) {
|
||||
for (final entity in dir.listSync(recursive: true)) {
|
||||
if (entity is File && entity.uri.pathSegments.last == name) return entity;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract a tar archive streaming entry-by-entry with minimal memory footprint.
|
||||
static Future<bool> _extractTar(File tarFile, Directory destDir) async {
|
||||
RandomAccessFile? raf;
|
||||
try {
|
||||
raf = await tarFile.open(mode: FileMode.read);
|
||||
final headerBuf = Uint8List(512);
|
||||
|
||||
while (true) {
|
||||
final bytesRead = await raf.readInto(headerBuf, 0, 512);
|
||||
if (bytesRead < 512) break;
|
||||
|
||||
// Check for EOF (two zero blocks or all zeros in 512-byte block)
|
||||
bool allZeros = true;
|
||||
for (int i = 0; i < 512; i++) {
|
||||
if (headerBuf[i] != 0) {
|
||||
allZeros = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allZeros) break;
|
||||
|
||||
// Filename: 0..99 bytes
|
||||
int nameLen = 0;
|
||||
while (nameLen < 100 && headerBuf[nameLen] != 0) {
|
||||
nameLen++;
|
||||
}
|
||||
if (nameLen == 0) continue;
|
||||
final rawName = String.fromCharCodes(headerBuf.sublist(0, nameLen));
|
||||
final safeName = rawName.replaceAll('\\', '/').split('/').last.trim();
|
||||
|
||||
// Size: 124..135 bytes (octal ascii)
|
||||
int sizeLen = 0;
|
||||
final sizeBytes = headerBuf.sublist(124, 136);
|
||||
while (sizeLen < sizeBytes.length &&
|
||||
sizeBytes[sizeLen] != 0 &&
|
||||
sizeBytes[sizeLen] != 32) {
|
||||
sizeLen++;
|
||||
}
|
||||
final sizeStr =
|
||||
String.fromCharCodes(sizeBytes.sublist(0, sizeLen)).trim();
|
||||
final fileSize = int.tryParse(sizeStr, radix: 8) ?? 0;
|
||||
|
||||
// Type flag: '0' (48) or 0 is regular file, '5' (53) is directory
|
||||
final typeFlag = headerBuf[156];
|
||||
final isDir = typeFlag == 53;
|
||||
|
||||
if (safeName.isNotEmpty && !isDir && fileSize > 0) {
|
||||
final outFile = File('${destDir.path}/$safeName');
|
||||
final outSink = outFile.openWrite();
|
||||
|
||||
int remaining = fileSize;
|
||||
const chunkSize = 64 * 1024;
|
||||
final buffer = Uint8List(chunkSize);
|
||||
|
||||
while (remaining > 0) {
|
||||
final toRead = remaining > chunkSize ? chunkSize : remaining;
|
||||
final readCount = await raf.readInto(buffer, 0, toRead);
|
||||
if (readCount <= 0) break;
|
||||
outSink.add(buffer.sublist(0, readCount));
|
||||
remaining -= readCount;
|
||||
}
|
||||
await outSink.flush();
|
||||
await outSink.close();
|
||||
|
||||
// Skip padding to 512-byte boundary
|
||||
final pad = (512 - (fileSize % 512)) % 512;
|
||||
if (pad > 0) {
|
||||
final curPos = await raf.position();
|
||||
await raf.setPosition(curPos + pad);
|
||||
}
|
||||
} else if (fileSize > 0) {
|
||||
final totalSkip = fileSize + ((512 - (fileSize % 512)) % 512);
|
||||
final curPos = await raf.position();
|
||||
await raf.setPosition(curPos + totalSkip);
|
||||
}
|
||||
}
|
||||
|
||||
return _findFile(destDir, 'valhalla_tiles.tar') != null ||
|
||||
_findFile(destDir, 'admins.sqlite') != null ||
|
||||
_findFile(destDir, 'jordan_roads.db') != null;
|
||||
} catch (e) {
|
||||
debugPrint('Tar extraction failed: $e');
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
await raf?.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 streamed over the file — لا نحمّل الأرشيف كاملاً في الذاكرة
|
||||
static Future<String> _sha256OfFile(File file) async {
|
||||
final sink = _DigestSink();
|
||||
final converter = sha256.startChunkedConversion(sink);
|
||||
await for (final chunk in file.openRead()) {
|
||||
converter.add(chunk);
|
||||
}
|
||||
converter.close();
|
||||
return sink.digest?.toString() ?? '';
|
||||
}
|
||||
|
||||
static String _formatBytes(int bytes) {
|
||||
if (bytes >= 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
if (bytes >= 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB';
|
||||
if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB';
|
||||
return '$bytes B';
|
||||
}
|
||||
}
|
||||
|
||||
class _DigestSink implements Sink<Digest> {
|
||||
Digest? digest;
|
||||
|
||||
@override
|
||||
void add(Digest data) => digest = data;
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
@@ -35,10 +35,14 @@ class TacticalApiService {
|
||||
final url = serverUrl ?? defaultServerUrl;
|
||||
final key = apiKey ?? defaultApiKey;
|
||||
try {
|
||||
final serverProfile = (profile == 'convoy' || profile == 'armored' || profile == 'offroad4x4' || profile == 'rapidResponse')
|
||||
? 'car'
|
||||
: profile;
|
||||
|
||||
final uri = Uri.parse(
|
||||
'$url/api/maps/route?fromLat=${origin.latitude}&fromLng=${origin.longitude}'
|
||||
'&toLat=${destination.latitude}&toLng=${destination.longitude}'
|
||||
'&profile=$profile&steps=true&locale=ar',
|
||||
'&profile=$serverProfile&steps=true&locale=ar',
|
||||
);
|
||||
|
||||
final resp = await http.get(uri, headers: {'x-api-key': key}).timeout(const Duration(seconds: 10));
|
||||
@@ -55,6 +59,12 @@ class TacticalApiService {
|
||||
distanceMeters = (data['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
durationSec = (data['duration'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
// Adjust tactical convoy duration
|
||||
if (profile == 'convoy') durationSec *= 1.45;
|
||||
if (profile == 'armored') durationSec *= 1.75;
|
||||
if (profile == 'offroad4x4') durationSec *= 1.1;
|
||||
if (profile == 'rapidResponse') durationSec *= 0.85;
|
||||
|
||||
if (data['points'] is String) {
|
||||
coords.addAll(PolylineUtils.decode(data['points'] as String));
|
||||
} else if (data['points'] is List) {
|
||||
|
||||
@@ -26,8 +26,15 @@ class TurnByTurnNavigationEngine {
|
||||
if (controller != null) _mapController = controller;
|
||||
stopNavigation();
|
||||
|
||||
final steps = _generateNavigationSteps(plan.polylinePoints, plan.tacticalWaypoints);
|
||||
// مناورات حقيقية من المحرك (أسماء شوارع فعلية + تعليمات عربية) عند توفرها
|
||||
final steps = (plan.maneuvers.isNotEmpty)
|
||||
? _stepsFromRealManeuvers(plan)
|
||||
: _generateNavigationSteps(plan.polylinePoints, plan.tacticalWaypoints);
|
||||
|
||||
final initialPos = plan.polylinePoints.isNotEmpty ? plan.polylinePoints.first : const LatLng(31.9539, 35.9106);
|
||||
final initialBearing = plan.polylinePoints.length >= 2
|
||||
? _calculateBearing(plan.polylinePoints[0], plan.polylinePoints[1])
|
||||
: 0.0;
|
||||
|
||||
navigationState.value = ActiveNavigationState(
|
||||
isNavigating: true,
|
||||
@@ -37,7 +44,7 @@ class TurnByTurnNavigationEngine {
|
||||
remainingDistanceKm: plan.totalDistanceKm,
|
||||
remainingDurationMinutes: plan.estimatedDurationMinutes,
|
||||
currentSpeedKmH: 60.0,
|
||||
currentHeadingDeg: _calculateBearing(plan.polylinePoints[0], plan.polylinePoints[1]),
|
||||
currentHeadingDeg: initialBearing,
|
||||
currentPosition: initialPos,
|
||||
isSimulating: simulate,
|
||||
);
|
||||
@@ -218,6 +225,68 @@ class TurnByTurnNavigationEngine {
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// Build navigation steps from real engine maneuvers (Valhalla narratives)
|
||||
static List<NavigationStep> _stepsFromRealManeuvers(OfflineRoutePlan plan) {
|
||||
final steps = <NavigationStep>[];
|
||||
final points = plan.polylinePoints;
|
||||
if (points.length < 2 || plan.maneuvers.isEmpty) return steps;
|
||||
|
||||
for (int i = 0; i < plan.maneuvers.length; i++) {
|
||||
final m = plan.maneuvers[i];
|
||||
final street = m.hasStreetName ? m.streetNames.first : 'طريق تكتيكي';
|
||||
final isLast = i == plan.maneuvers.length - 1;
|
||||
|
||||
steps.add(NavigationStep(
|
||||
instructionAr: isLast ? 'وصلت إلى الهدف التكتيكي' : m.instructionAr,
|
||||
streetName: street,
|
||||
maneuver: _mapManeuverType(m.type),
|
||||
distanceMeters: m.lengthKm * 1000.0,
|
||||
location: m.location,
|
||||
));
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// Map numeric Valhalla maneuver types to local ManeuverType icons
|
||||
static ManeuverType _mapManeuverType(int valhallaType) {
|
||||
// Valhalla ManeuverType numeric codes (see valhalla documentation)
|
||||
switch (valhallaType) {
|
||||
case 4:
|
||||
case 5:
|
||||
case 6:
|
||||
return ManeuverType.arrive; // destination
|
||||
case 9:
|
||||
return ManeuverType.slightRight;
|
||||
case 10:
|
||||
return ManeuverType.right;
|
||||
case 11:
|
||||
return ManeuverType.sharpRight;
|
||||
case 12:
|
||||
case 13:
|
||||
return ManeuverType.uTurn;
|
||||
case 14:
|
||||
return ManeuverType.sharpLeft;
|
||||
case 15:
|
||||
return ManeuverType.left;
|
||||
case 16:
|
||||
return ManeuverType.slightLeft;
|
||||
case 23:
|
||||
return ManeuverType.keepRight;
|
||||
case 24:
|
||||
return ManeuverType.keepLeft;
|
||||
case 26:
|
||||
case 27:
|
||||
return ManeuverType.roundabout;
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
case 7:
|
||||
case 8:
|
||||
default:
|
||||
return ManeuverType.straight;
|
||||
}
|
||||
}
|
||||
|
||||
static double _calculateBearing(LatLng start, LatLng end) {
|
||||
final lat1 = start.latitude * (math.pi / 180.0);
|
||||
final lon1 = start.longitude * (math.pi / 180.0);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import 'offline_routing_engine.dart';
|
||||
|
||||
/// [ValhallaOfflineEngine] - محرك التوجيه المحلي السيادي (100% على الجهاز)
|
||||
///
|
||||
/// English:
|
||||
/// Dart bridge to the native valhalla-mobile routing engine (Android/iOS).
|
||||
/// Routes are calculated against a downloaded Jordan tile extract
|
||||
/// (valhalla_tiles.tar built from the same OSM data as the server GraphHopper,
|
||||
/// with SRTM elevation baked into every edge). Zero network usage.
|
||||
///
|
||||
/// العربية:
|
||||
/// جسر Dart إلى محرك Valhalla الأصلي على الجهاز. يحسب المسارات على شبكة الطرق
|
||||
/// الحقيقية للأردن من حزمة التوجيه المنزّلة (بنفس بيانات OSM وارتفاعات SRTM
|
||||
/// المستخدمة في GraphHopper على السيرفر) بدون أي اتصال شبكي.
|
||||
class ValhallaOfflineEngine {
|
||||
static const MethodChannel _channel = MethodChannel('intaleq_tactical/valhalla');
|
||||
|
||||
/// Warm up / (re)build the native engine against [regionDir] tiles.
|
||||
/// Returns true when the native library answered (engine usable).
|
||||
static Future<bool> ensureReady({String? regionDir}) async {
|
||||
try {
|
||||
// فحص مسبق محلي: لا نزعج الجسر الأصلي إذا لم تُثبَّت الحزمة أصلاً
|
||||
final dir = regionDir;
|
||||
if (dir != null && !Directory(dir).existsSync()) {
|
||||
debugPrint('ValhallaOfflineEngine: package not installed at $dir');
|
||||
return false;
|
||||
}
|
||||
await _channel.invokeMethod<bool>('ensureReady', {'regionDir': regionDir});
|
||||
return true;
|
||||
} on MissingPluginException {
|
||||
debugPrint('ValhallaOfflineEngine: native bridge not available on this platform');
|
||||
return false;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('ValhallaOfflineEngine ensureReady failed: ${e.code} ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('ValhallaOfflineEngine ensureReady error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> release() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('release');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Calculate a fully offline road-following route.
|
||||
///
|
||||
/// Returns null when the engine/package is unavailable so callers can fall
|
||||
/// back to the legacy synthetic graph.
|
||||
static Future<OfflineRoutePlan?> calculateOfflineRoute({
|
||||
required LatLng start,
|
||||
required LatLng destination,
|
||||
TacticalVehicleProfile profile = TacticalVehicleProfile.convoy,
|
||||
String? regionDir,
|
||||
}) async {
|
||||
try {
|
||||
final ready = await ensureReady(regionDir: regionDir);
|
||||
if (!ready) return null;
|
||||
|
||||
final request = jsonEncode({
|
||||
'locations': [
|
||||
{'lat': start.latitude, 'lon': start.longitude, 'type': 'break'},
|
||||
{'lat': destination.latitude, 'lon': destination.longitude, 'type': 'break'},
|
||||
],
|
||||
'costing': 'auto',
|
||||
'alternatives': false,
|
||||
'directions_options': {
|
||||
'units': 'kilometers',
|
||||
'language': 'ar',
|
||||
'directions_type': 'instructions',
|
||||
},
|
||||
'id': 'intaleq_tactical',
|
||||
});
|
||||
|
||||
final raw = await _channel.invokeMethod<String>('route', {'request': request});
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return _parseTripResponse(data, profile);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('ValhallaOfflineEngine route failed: ${e.code} ${e.message}');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('ValhallaOfflineEngine route error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response Parsing ─────────────────────────────────────────────────────
|
||||
|
||||
static OfflineRoutePlan? _parseTripResponse(Map<String, dynamic> data, TacticalVehicleProfile profile) {
|
||||
final trip = data['trip'];
|
||||
if (trip is! Map<String, dynamic>) return null;
|
||||
|
||||
final status = (trip['status'] as num?)?.toInt() ?? -1;
|
||||
if (status != 0) {
|
||||
debugPrint('Valhalla trip status $status: ${trip['status_message']}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final legs = trip['legs'];
|
||||
if (legs is! List || legs.isEmpty) return null;
|
||||
|
||||
final allPoints = <LatLng>[];
|
||||
final maneuvers = <RouteManeuver>[];
|
||||
|
||||
for (final legDynamic in legs) {
|
||||
if (legDynamic is! Map<String, dynamic>) continue;
|
||||
|
||||
// Shape: Valhalla encoded polyline, precision 6, includes altitude when
|
||||
// the tiles were built with SRTM elevation.
|
||||
final shapeStr = legDynamic['shape'] as String?;
|
||||
if (shapeStr == null) continue;
|
||||
final legPoints = decodePolyline6(shapeStr);
|
||||
final legOffset = allPoints.isEmpty ? 0 : allPoints.length - 1; // join at shared point
|
||||
|
||||
allPoints.addAll(legOffset == 0 ? legPoints : legPoints.skip(1));
|
||||
|
||||
// Maneuvers with real street names + Arabic narrative instructions.
|
||||
final legManeuvers = legDynamic['maneuvers'];
|
||||
if (legManeuvers is List) {
|
||||
for (final m in legManeuvers) {
|
||||
if (m is! Map<String, dynamic>) continue;
|
||||
final beginIdx = (m['begin_shape_index'] as num?)?.toInt() ?? 0;
|
||||
final globalIdx = (legOffset + beginIdx).clamp(0, allPoints.length - 1);
|
||||
|
||||
final streetNames = <String>[];
|
||||
final rawStreets = m['street_names'];
|
||||
if (rawStreets is List) {
|
||||
for (final s in rawStreets) {
|
||||
if (s is String && s.isNotEmpty) streetNames.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
maneuvers.add(RouteManeuver(
|
||||
instructionAr: (m['instruction'] as String?) ?? 'واصل السير',
|
||||
type: (m['type'] as num?)?.toInt() ?? 0,
|
||||
streetNames: streetNames,
|
||||
lengthKm: (m['length'] as num?)?.toDouble() ?? 0.0,
|
||||
location: allPoints[globalIdx],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allPoints.length < 2) return null;
|
||||
|
||||
final summary = trip['summary'];
|
||||
double totalKm = 0.0;
|
||||
double totalTimeSec = 0.0;
|
||||
double ascend = 0.0;
|
||||
double descend = 0.0;
|
||||
if (summary is Map<String, dynamic>) {
|
||||
totalKm = (summary['length'] as num?)?.toDouble() ?? 0.0;
|
||||
totalTimeSec = (summary['time'] as num?)?.toDouble() ?? 0.0;
|
||||
ascend = (summary['ascend'] as num?)?.toDouble() ?? 0.0;
|
||||
descend = (summary['descend'] as num?)?.toDouble() ?? 0.0;
|
||||
}
|
||||
if (totalKm <= 0) {
|
||||
totalKm = _haversineKm(allPoints.first, allPoints.last);
|
||||
}
|
||||
|
||||
// نفس معاملات التعديل التكتيكي المستخدمة في مسار السيرفر للحفاظ على الاتساق
|
||||
double profileFactor;
|
||||
switch (profile) {
|
||||
case TacticalVehicleProfile.convoy:
|
||||
profileFactor = 1.45;
|
||||
break;
|
||||
case TacticalVehicleProfile.armored:
|
||||
profileFactor = 1.75;
|
||||
break;
|
||||
case TacticalVehicleProfile.offroad4x4:
|
||||
profileFactor = 1.1;
|
||||
break;
|
||||
case TacticalVehicleProfile.rapidResponse:
|
||||
profileFactor = 0.85;
|
||||
break;
|
||||
}
|
||||
|
||||
return OfflineRoutePlan(
|
||||
polylinePoints: allPoints,
|
||||
totalDistanceKm: totalKm,
|
||||
estimatedDurationMinutes: (totalTimeSec / 60.0) * profileFactor,
|
||||
profile: profile,
|
||||
tacticalWaypoints: const ['موقع الانطلاق', 'الهدف التكتيكي المحدد'],
|
||||
isOffline: true,
|
||||
elevationGainMeters: ascend.round(),
|
||||
elevationLossMeters: descend.round(),
|
||||
usesRealRoadNetwork: true,
|
||||
maneuvers: maneuvers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Decode Valhalla encoded polyline (precision 1e6, optional 3rd dim = altitude cm).
|
||||
static List<LatLng> decodePolyline6(String encoded) {
|
||||
final points = <LatLng>[];
|
||||
int index = 0;
|
||||
int lat = 0;
|
||||
int lng = 0;
|
||||
|
||||
int decodeChunk() {
|
||||
int result = 0;
|
||||
int shift = 0;
|
||||
int b;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20 && index < encoded.length);
|
||||
return (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
}
|
||||
|
||||
while (index < encoded.length) {
|
||||
lat += decodeChunk();
|
||||
lng += decodeChunk();
|
||||
points.add(LatLng(lat / 1e6, lng / 1e6));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
static double _haversineKm(LatLng a, LatLng b) {
|
||||
const r = 6371.0;
|
||||
final dLat = (b.latitude - a.latitude) * (math.pi / 180.0);
|
||||
final dLng = (b.longitude - a.longitude) * (math.pi / 180.0);
|
||||
final la1 = a.latitude * (math.pi / 180.0);
|
||||
final la2 = b.latitude * (math.pi / 180.0);
|
||||
final h = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(la1) * math.cos(la2) * math.sin(dLng / 2) * math.sin(dLng / 2);
|
||||
return 2 * r * math.asin(math.sqrt(h));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user