Files
maps-saas/packages/tactical_app/lib/services/tactical_api_service.dart
T

240 lines
8.8 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:intaleq_maps/intaleq_maps.dart';
import '../config/app_config.dart';
import '../models/landmark.dart';
import 'offline_los_engine.dart';
class TacticalRouteResult {
final List<LatLng> points;
final double distanceKm;
final double durationMinutes;
final String profile;
TacticalRouteResult({
required this.points,
required this.distanceKm,
required this.durationMinutes,
required this.profile,
});
}
class TacticalApiService {
static String get defaultServerUrl => AppConfig.serverUrl;
static String get defaultApiKey => AppConfig.apiKey;
/// Calculate tactical route between waypoints using server routing engine
static Future<TacticalRouteResult?> calculateTacticalRoute({
required LatLng origin,
required LatLng destination,
String profile = 'car', // 'car', 'offroad', 'convoy', 'emergency'
String? serverUrl,
String? apiKey,
}) async {
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=$serverProfile&steps=true&locale=ar',
);
final resp = await http.get(uri, headers: {'x-api-key': key}).timeout(const Duration(seconds: 10));
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body);
double distanceMeters = 0.0;
double durationSec = 0.0;
final coords = <LatLng>[];
// 1. Direct NestJS Map-SaaS API response format
if (data['distance'] != null) {
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) {
for (final pt in data['points']) {
if (pt is List && pt.length >= 2) {
coords.add(LatLng((pt[1] as num).toDouble(), (pt[0] as num).toDouble()));
}
}
} else if (data['points'] is Map && data['points']['coordinates'] != null) {
final List<dynamic> coordList = data['points']['coordinates'];
for (final c in coordList) {
coords.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()));
}
}
}
// 2. Fallback to raw / paths GraphHopper format
else if (data['paths'] != null && (data['paths'] as List).isNotEmpty) {
final path = data['paths'][0];
distanceMeters = (path['distance'] as num?)?.toDouble() ?? 0.0;
final timeMs = (path['time'] as num?)?.toDouble() ?? 0.0;
durationSec = timeMs / 1000.0;
if (path['points'] is String) {
coords.addAll(PolylineUtils.decode(path['points'] as String));
} else if (path['points'] is Map && path['points']['coordinates'] != null) {
final List<dynamic> coordList = path['points']['coordinates'];
for (final c in coordList) {
coords.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()));
}
}
}
if (coords.isNotEmpty) {
return TacticalRouteResult(
points: coords,
distanceKm: distanceMeters / 1000.0,
durationMinutes: durationSec / 60.0,
profile: profile,
);
}
}
} catch (e) {
debugPrint('Tactical route API error: $e');
}
return null;
}
/// Search places via Server Geocoding API (Forward Geocoding & POI Search)
static Future<List<TacticalLandmark>> searchOnlinePlaces(String query, {String? region}) async {
final cleanQuery = query.trim();
if (cleanQuery.isEmpty) return [];
try {
final uri = Uri.parse(
'$defaultServerUrl/api/geocoding/search?q=${Uri.encodeComponent(cleanQuery)}&country=jordan',
);
final resp = await http.get(uri, headers: {'x-api-key': defaultApiKey}).timeout(const Duration(seconds: 4));
if (resp.statusCode == 200) {
final data = jsonDecode(resp.body);
final list = (data is List) ? data : (data['results'] is List ? data['results'] as List : []);
return list.map((item) {
final name = item['name_ar'] ?? item['name'] ?? cleanQuery;
final cat = (item['category'] ?? item['type'] ?? '').toString().toLowerCase();
LandmarkType type = LandmarkType.tower;
if (cat.contains('worship') || cat.contains('mosque') || name.contains('مسجد') || name.contains('جامع')) {
type = LandmarkType.minaret;
} else if (cat.contains('water') || name.contains('خزان') || name.contains('بركة')) {
type = LandmarkType.waterTank;
} else if (cat.contains('fort') || cat.contains('castle') || name.contains('قلعة') || name.contains('قصر')) {
type = LandmarkType.fort;
} else if (cat.contains('mountain') || cat.contains('peak') || name.contains('جبل') || name.contains('تل')) {
type = LandmarkType.mountain;
}
final lat = (item['latitude'] ?? item['lat'] as num?)?.toDouble() ?? 32.0;
final lng = (item['longitude'] ?? item['lng'] as num?)?.toDouble() ?? 36.0;
final regionName = item['governorate'] ?? item['district'] ?? item['city'] ?? 'الأردن';
return TacticalLandmark(
id: 'geo-${item['id'] ?? lat}_$lng',
name: name,
region: regionName,
type: type,
lat: lat,
lng: lng,
elevationM: (item['elevation'] as num?)?.toInt() ?? 750,
description: item['neighbourhood'] ?? item['formatted_address'] ?? '',
);
}).toList();
}
} catch (e) {
debugPrint('Online geocoding search error: $e');
}
return [];
}
/// Calculate Tactical Line of Sight (LOS) between Observer and Target
static Future<Map<String, dynamic>?> calculateLineOfSight({
required LatLng observer,
required LatLng target,
double observerHeight = 2.0,
double targetHeight = 2.0,
String? serverUrl,
String? apiKey,
}) async {
final url = serverUrl ?? defaultServerUrl;
final key = apiKey ?? defaultApiKey;
try {
final uri = Uri.parse(
'$url/api/tactical/line-of-sight?observerLat=${observer.latitude}&observerLng=${observer.longitude}'
'&targetLat=${target.latitude}&targetLng=${target.longitude}'
'&observerHeight=$observerHeight&targetHeight=$targetHeight',
);
final resp = await http.get(uri, headers: {'x-api-key': key}).timeout(const Duration(seconds: 8));
if (resp.statusCode == 200) {
return jsonDecode(resp.body);
}
} catch (e) {
debugPrint('Tactical LOS API error: $e');
}
return null;
}
/// Sovereign Line of Sight solver with zero latency and full offline capability
static Future<OfflineLosReport> calculateHybridLineOfSight({
required LatLng observer,
required LatLng target,
double observerHeight = 2.0,
double targetHeight = 2.0,
String? serverUrl,
String? apiKey,
}) async {
return OfflineLosEngine.calculateAsync(
observer: observer,
target: target,
observerHeightM: observerHeight,
targetHeightM: targetHeight,
);
}
/// Calculate Tactical Isochrone Reachability Polygons
static Future<Map<String, dynamic>?> calculateIsochrone({
required LatLng center,
List<int> timeBuckets = const [300, 600, 900], // 5, 10, 15 minutes
String? serverUrl,
String? apiKey,
}) async {
final url = serverUrl ?? defaultServerUrl;
final key = apiKey ?? defaultApiKey;
try {
final timesStr = timeBuckets.join(',');
final uri = Uri.parse(
'$url/api/tactical/isochrone?lat=${center.latitude}&lng=${center.longitude}&times=$timesStr&profile=emergency',
);
final resp = await http.get(uri, headers: {'x-api-key': key}).timeout(const Duration(seconds: 10));
if (resp.statusCode == 200) {
return jsonDecode(resp.body);
}
} catch (e) {
debugPrint('Tactical Isochrone API error: $e');
}
return null;
}
}