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 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('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 release() async { try { await _channel.invokeMethod('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 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('route', {'request': request}); if (raw == null || raw.isEmpty) return null; final data = jsonDecode(raw) as Map; 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 data, TacticalVehicleProfile profile) { final trip = data['trip']; if (trip is! Map) 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 = []; final maneuvers = []; for (final legDynamic in legs) { if (legDynamic is! Map) 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) continue; final beginIdx = (m['begin_shape_index'] as num?)?.toInt() ?? 0; final globalIdx = (legOffset + beginIdx).clamp(0, allPoints.length - 1); final streetNames = []; 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) { 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 decodePolyline6(String encoded) { final points = []; 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)); } }