chore: add build artifacts, map assets, and routing data
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user