chore: add build artifacts, map assets, and routing data

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:24 +03:00
parent aa2b9f131f
commit 43ad2e0ad4
327 changed files with 52421 additions and 1810 deletions
@@ -0,0 +1,32 @@
class HazardModel {
final String type; // 'accident', 'closure', 'bump', 'checkpoint', 'camera'
final String title;
final String description;
final double latitude;
final double longitude;
final double altitude; // Altitude AMSL in meters (defaults to 0.0)
final DateTime createdAt;
HazardModel({
required this.type,
required this.title,
required this.description,
required this.latitude,
required this.longitude,
this.altitude = 0.0,
required this.createdAt,
});
Map<String, dynamic> toJson() {
return {
'type': type,
'title': title,
'description': description,
'latitude': latitude,
'longitude': longitude,
'altitude': altitude,
'elevation_meters': altitude,
'timestamp': createdAt.toIso8601String(),
};
}
}
@@ -0,0 +1,57 @@
class PlaceModel {
final String id;
final String name;
final String category;
final double latitude;
final double longitude;
final double elevationMeters; // GPS Altitude AMSL in meters (defaults to 0.0)
final double? distanceKm;
final String? address;
PlaceModel({
required this.id,
required this.name,
required this.category,
required this.latitude,
required this.longitude,
this.elevationMeters = 0.0,
this.distanceKm,
this.address,
});
factory PlaceModel.fromJson(Map<String, dynamic> json) {
final rawElev = json['elevation_meters'] ?? json['elevation'] ?? json['altitude'] ?? 0.0;
double elev = 0.0;
if (rawElev is num) {
elev = rawElev.toDouble();
} else if (rawElev != null) {
elev = double.tryParse(rawElev.toString()) ?? 0.0;
}
return PlaceModel(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
category: json['category']?.toString() ?? 'other',
latitude: double.tryParse(json['latitude']?.toString() ?? json['lat']?.toString() ?? '0') ?? 0.0,
longitude: double.tryParse(json['longitude']?.toString() ?? json['lng']?.toString() ?? '0') ?? 0.0,
elevationMeters: elev,
distanceKm: json['distanceKm'] != null
? (json['distanceKm'] as num).toDouble()
: null,
address: json['address']?.toString() ?? json['neighborhood']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'category': category,
'latitude': latitude,
'longitude': longitude,
'elevation_meters': elevationMeters,
'altitude': elevationMeters,
if (address != null) 'address': address,
};
}
}
@@ -0,0 +1,60 @@
import 'package:intaleq_maps/intaleq_maps.dart';
class RouteData {
final List<LatLng> coordinates;
final List<Map<String, dynamic>> steps;
final double distanceM;
final double durationS;
final String points;
final String routeName;
final List<String> tags;
final String? slopeWarning;
final bool hasSteepSlope;
final double maxInclinePercent;
final double maxDeclinePercent;
final double totalAscentMeters;
final double totalDescentMeters;
final double fuelLiters;
final double fuelSavingsPercent;
final int ecoScore;
final bool isEcoFriendly;
RouteData({
required this.coordinates,
required this.steps,
required this.distanceM,
required this.durationS,
required this.points,
this.routeName = 'المسار المباشر الأسرع',
this.tags = const [],
this.slopeWarning,
this.hasSteepSlope = false,
this.maxInclinePercent = 0.0,
this.maxDeclinePercent = 0.0,
this.totalAscentMeters = 0.0,
this.totalDescentMeters = 0.0,
this.fuelLiters = 0.0,
this.fuelSavingsPercent = 0.0,
this.ecoScore = 0,
this.isEcoFriendly = false,
});
bool get isFastest => tags.contains('FASTEST');
String get formattedDistance {
if (distanceM >= 1000) {
return '${(distanceM / 1000).toStringAsFixed(1)} كم';
}
return '${distanceM.round()} م';
}
String get formattedDuration {
final int minutes = (durationS / 60).round();
if (minutes >= 60) {
final int hours = minutes ~/ 60;
final int remainingMinutes = minutes % 60;
return '$hours س $remainingMinutes د';
}
return '$minutes دقيقة';
}
}
@@ -0,0 +1,328 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:intaleq_maps/intaleq_maps.dart';
import '../../core/constants/api_constants.dart';
import '../../core/utils/polyline_decoder.dart';
import '../../core/services/location_service.dart';
import '../models/route_model.dart';
import '../models/place_model.dart';
import '../models/hazard_model.dart';
class MapSaasRepository {
final http.Client client;
MapSaasRepository({http.Client? client}) : client = client ?? http.Client();
/// Fetch primary and alternative routes from MapSaaS
Future<List<RouteData>> getRoute({
required LatLng origin,
required LatLng destination,
LatLng? intermediateStop,
String lang = 'ar',
}) async {
final Map<String, String> queryParams = {
'fromLat': origin.latitude.toString(),
'fromLng': origin.longitude.toString(),
'toLat': destination.latitude.toString(),
'toLng': destination.longitude.toString(),
'steps': 'true',
'alternatives': 'true',
'locale': lang,
};
if (intermediateStop != null) {
queryParams['stop1Lat'] = intermediateStop.latitude.toString();
queryParams['stop1Lng'] = intermediateStop.longitude.toString();
}
final saasUri = Uri.parse(ApiConstants.mapSaasRoute).replace(queryParameters: queryParams);
print("🌐 [MapSaasRepo] GET route request: $saasUri");
try {
final response = await client.get(
saasUri,
headers: {'x-api-key': ApiConstants.mapSaasKey},
);
print("📥 [MapSaasRepo] Route response HTTP status: ${response.statusCode} (${response.body.length} bytes)");
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final List<RouteData> routes = [];
// Parse primary route
final primaryPts = data['points']?.toString() ?? '';
if (primaryPts.isNotEmpty) {
final coords = await compute<String, List<LatLng>>(
decodePolylineIsolate,
primaryPts,
);
routes.add(_parseRouteData(
item: Map<String, dynamic>.from(data),
coords: coords,
points: primaryPts,
index: 0,
));
}
// Parse alternative routes
if (data['alternatives'] != null && data['alternatives'] is List) {
int altIdx = 1;
for (var alt in data['alternatives']) {
final altPts = alt['points']?.toString() ?? '';
if (altPts.isEmpty) continue;
final altCoords = await compute<String, List<LatLng>>(
decodePolylineIsolate,
altPts,
);
routes.add(_parseRouteData(
item: Map<String, dynamic>.from(alt),
coords: altCoords,
points: altPts,
index: altIdx++,
));
}
}
print("✅ [MapSaasRepo] Parsed ${routes.length} routes from MapSaaS successfully");
return routes;
} else {
print("⚠️ [MapSaasRepo] Route fetch returned non-200 status: ${response.statusCode}, body: ${response.body}");
}
} catch (e) {
print("❌ [MapSaasRepo] getRoute exception: $e");
}
return [];
}
RouteData _parseRouteData({
required Map<String, dynamic> item,
required List<LatLng> coords,
required String points,
int index = 0,
}) {
final elev = item['elevationSummary'] as Map<String, dynamic>?;
final eco = item['ecoMetrics'] as Map<String, dynamic>?;
final rawTags = item['tags'];
final tags = rawTags is List ? rawTags.map((t) => t.toString()).toList() : <String>[];
final rawSteps = List<Map<String, dynamic>>.from(item['instructions'] ?? []);
final enrichedSteps = rawSteps.map((inst) {
final copy = Map<String, dynamic>.from(inst);
final interval = copy['interval'];
if (interval is List && interval.length >= 2) {
final endIdx = (interval[1] as num).toInt();
if (endIdx >= 0 && endIdx < coords.length) {
copy['lat'] = coords[endIdx].latitude;
copy['lng'] = coords[endIdx].longitude;
}
}
return copy;
}).toList();
return RouteData(
coordinates: coords,
steps: enrichedSteps,
distanceM: (item['distance'] as num?)?.toDouble() ?? 0.0,
durationS: (item['duration'] as num?)?.toDouble() ?? 0.0,
points: points,
routeName: item['routeName']?.toString() ?? (index == 0 ? 'المسار 1: الأسرع' : 'المسار 2: مسار بديل'),
tags: tags,
slopeWarning: elev?['slopeWarning']?.toString(),
hasSteepSlope: (elev?['hasSteepIncline'] == true) || (elev?['hasSteepDecline'] == true),
maxInclinePercent: (elev?['maxInclinePercent'] as num?)?.toDouble() ?? 0.0,
maxDeclinePercent: (elev?['maxDeclinePercent'] as num?)?.toDouble() ?? 0.0,
totalAscentMeters: (elev?['totalAscentMeters'] as num?)?.toDouble() ?? 0.0,
totalDescentMeters: (elev?['totalDescentMeters'] as num?)?.toDouble() ?? 0.0,
fuelLiters: (eco?['fuelLiters'] as num?)?.toDouble() ?? 0.0,
fuelSavingsPercent: (eco?['fuelSavingsPercent'] as num?)?.toDouble() ?? 0.0,
ecoScore: (eco?['ecoScore'] as num?)?.toInt() ?? 0,
isEcoFriendly: eco?['isEcoFriendly'] == true || tags.contains('ECO_FRIENDLY'),
);
}
/// Search places via MapSaaS geocoding API with Google Places fallback strictly within 50 km
Future<List<PlaceModel>> searchPlaces({
required String query,
LatLng? userLocation,
String country = 'jordan',
}) async {
final center = userLocation ?? const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng);
List<PlaceModel> places = [];
// 1. Try MapSaaS Primary Geocoding Search Endpoint (Within 50 km)
try {
final Map<String, String> queryParams = {
'q': query,
'country': country,
'lat': center.latitude.toString(),
'lng': center.longitude.toString(),
'radius': ApiConstants.maxSearchRadiusMeters.toInt().toString(), // 50000 m
};
final uri = Uri.parse(ApiConstants.mapSaasSearch).replace(queryParameters: queryParams);
final response = await client.get(
uri,
headers: {'x-api-key': ApiConstants.mapSaasKey},
);
if (response.statusCode == 200) {
final dynamic decoded = jsonDecode(response.body);
List<dynamic> items = [];
if (decoded is List) {
items = decoded;
} else if (decoded is Map && decoded['data'] is List) {
items = decoded['data'];
} else if (decoded is Map && decoded['results'] is List) {
items = decoded['results'];
}
places = items
.map((e) => PlaceModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
} catch (_) {}
// 2. If MapSaaS returned empty, fallback to Google Places Nearbysearch (exact Siro logic, 50 km)
if (places.isEmpty && ApiConstants.googleMapApiKey.isNotEmpty) {
try {
final gUri = Uri.parse(ApiConstants.googlePlacesNearby).replace(queryParameters: {
'keyword': query,
'location': '${center.latitude},${center.longitude}',
'radius': ApiConstants.maxSearchRadiusMeters.toInt().toString(), // 50000 m
'language': 'ar',
'key': ApiConstants.googleMapApiKey,
});
final gResponse = await client.get(gUri);
if (gResponse.statusCode == 200) {
final gDecoded = jsonDecode(gResponse.body);
if (gDecoded['results'] is List) {
final List<dynamic> gResults = gDecoded['results'];
places = gResults.map((r) {
final loc = r['geometry']?['location'];
final lat = (loc?['lat'] as num?)?.toDouble() ?? 0.0;
final lng = (loc?['lng'] as num?)?.toDouble() ?? 0.0;
final name = r['name']?.toString() ?? query;
final vicinity = r['vicinity']?.toString() ?? r['formatted_address']?.toString();
final types = r['types'] is List ? (r['types'] as List).map((t) => t.toString()).toList() : <String>[];
final category = types.isNotEmpty ? types.first : 'place';
return PlaceModel(
id: r['place_id']?.toString() ?? name,
name: name,
category: category,
latitude: lat,
longitude: lng,
address: vicinity,
elevationMeters: 0.0,
);
}).toList();
}
}
} catch (_) {}
}
// 3. Strict 50 km radius enforcement and proximity sorting
final filteredPlaces = places.where((p) {
final distM = LocationService.instance.calculateDistance(
center,
LatLng(p.latitude, p.longitude),
);
return distM <= ApiConstants.maxSearchRadiusMeters;
}).toList();
// Sort by proximity ascending (closest first)
filteredPlaces.sort((a, b) {
final distA = LocationService.instance.calculateDistance(
center,
LatLng(a.latitude, a.longitude),
);
final distB = LocationService.instance.calculateDistance(
center,
LatLng(b.latitude, b.longitude),
);
return distA.compareTo(distB);
});
return filteredPlaces;
}
/// Submit place addition to MapSaaS
Future<bool> submitNewPlace({
required String name,
required String category,
required LatLng position,
double altitude = 0.0,
String country = 'jordan',
}) async {
final uri = Uri.parse(ApiConstants.mapSaasPlaces);
try {
final response = await client.post(
uri,
headers: {
'x-api-key': ApiConstants.mapSaasKey,
'Content-Type': 'application/json',
},
body: jsonEncode({
'name': name,
'category': category,
'lat': position.latitude,
'lng': position.longitude,
'altitude': altitude,
'elevation_meters': altitude,
'country': country,
}),
);
return response.statusCode == 200 || response.statusCode == 201;
} catch (_) {
return false;
}
}
/// Report a road hazard (Accident, Police, Closed Road, Speed Bump)
Future<bool> reportHazard(HazardModel hazard) async {
// Structured and ready for the backend road telemetry ingestion
return true;
}
/// Ingest live driver telemetry including altitude/elevation and distance
Future<bool> sendDriverTelemetry({
required String driverId,
required double latitude,
required double longitude,
required double speed,
required double heading,
double distance = 0.0,
double elevation = 0.0,
}) async {
final uri = Uri.parse(ApiConstants.mapSaasTelemetry);
try {
final payload = {
'driver_id': driverId,
'latitude': latitude,
'longitude': longitude,
'speed': speed,
'heading': heading,
'distance': distance,
'elevation': elevation,
};
final response = await client.post(
uri,
headers: {
'x-api-key': ApiConstants.mapSaasKey,
'Content-Type': 'application/json',
},
body: jsonEncode(payload),
);
final ok = response.statusCode == 200 || response.statusCode == 201;
if (ok) {
print("📡 [MapSaasRepo] Telemetry sent: driver=$driverId, lat=${latitude.toStringAsFixed(4)}, lng=${longitude.toStringAsFixed(4)}, alt=${elevation.toStringAsFixed(1)}m, speed=${speed.toStringAsFixed(1)}km/h");
} else {
print("⚠️ [MapSaasRepo] Telemetry HTTP ${response.statusCode}: ${response.body}");
}
return ok;
} catch (e) {
print("❌ [MapSaasRepo] sendDriverTelemetry error: $e");
return false;
}
}
}