MotorSignal-App/lib/services/routes/route_provider.dart

261 lines
7.3 KiB
Dart

import '../map/map_provider_interface.dart';
import 'route_models.dart';
/// Exceptions domain mapped from standard provider API.
class RouteProviderException implements Exception {
final String code;
final String message;
RouteProviderException(this.code, this.message);
@override
String toString() => 'RouteProviderException: $message ($code)';
}
/// Result of a place/location search query.
class PlaceSearchResult {
final String providerId;
final String primaryText;
final String secondaryText;
final double? latitude;
final double? longitude;
final String? formattedAddress;
final String? attribution;
const PlaceSearchResult({
required this.providerId,
required this.primaryText,
required this.secondaryText,
this.latitude,
this.longitude,
this.formattedAddress,
this.attribution,
});
bool get hasCoordinates => latitude != null && longitude != null;
AppLatLng? get latLng =>
hasCoordinates ? AppLatLng(latitude!, longitude!) : null;
/// Converts directly into a [ResolvedPlace] when coordinates are already present.
ResolvedPlace? toResolvedPlace() {
if (!hasCoordinates) return null;
return ResolvedPlace(
providerId: providerId,
displayName: primaryText,
formattedAddress: formattedAddress ??
(secondaryText.isNotEmpty
? '$primaryText, $secondaryText'
: primaryText),
latitude: latitude!,
longitude: longitude!,
attribution: attribution,
);
}
}
/// Resolved place with authoritative coordinates.
class ResolvedPlace {
final String providerId;
final String displayName;
final String? formattedAddress;
final double latitude;
final double longitude;
final String? attribution;
const ResolvedPlace({
required this.providerId,
required this.displayName,
this.formattedAddress,
required this.latitude,
required this.longitude,
this.attribution,
});
AppLatLng get latLng => AppLatLng(latitude, longitude);
}
/// Result of calculating a route along ordered stops.
class RouteCalculationResult {
final double straightLineDistanceMeters;
final double? estimatedDistanceMeters;
final int? estimatedDurationSeconds;
final List<AppLatLng> geometry;
final bool isStraightLine;
final String providerName;
final List<String> warnings;
final List<RouteManeuver> maneuvers;
final RoutingOptions? options;
final double? curvatureMetric;
const RouteCalculationResult({
required this.straightLineDistanceMeters,
this.estimatedDistanceMeters,
this.estimatedDurationSeconds,
required this.geometry,
required this.isStraightLine,
required this.providerName,
this.warnings = const [],
this.maneuvers = const [],
this.options,
this.curvatureMetric,
});
/// Alias for geometry points.
List<AppLatLng> get points => geometry;
/// Total straight-line distance in meters.
double get totalDistanceMeters => straightLineDistanceMeters;
/// Alias for isStraightLine.
bool get isHonestStraightLine => isStraightLine;
/// Total distance in miles.
double get distanceMiles {
final meters = estimatedDistanceMeters ?? straightLineDistanceMeters;
return meters * 0.000621371;
}
/// Truthful distance description.
String get distanceLabel {
if (estimatedDistanceMeters != null) {
return '${distanceMiles.toStringAsFixed(1)} mi (Road Est.)';
}
return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)';
}
/// Human-readable estimated travel time.
String get formattedDuration {
if (estimatedDurationSeconds == null) return '-- min';
final totalSec = estimatedDurationSeconds!;
final hours = totalSec ~/ 3600;
final minutes = (totalSec % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes.clamp(1, 60)} min';
}
/// Categorized motorcycle twistiness rating based on curvature metric (degrees per mile).
String get twistinessRating {
if (curvatureMetric == null) return 'Standard';
final c = curvatureMetric!;
if (c >= 120.0) return 'Very Twisty (Canyon)';
if (c >= 60.0) return 'Twisty Curves';
if (c >= 25.0) return 'Moderate Curves';
return 'Highway / Straight';
}
}
/// Abstract provider interface for calculating routes and searching places.
abstract class RouteProvider {
String get name;
/// Calculates route geometry and metrics between ordered stops.
Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
});
/// Searches for places by textual query.
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
});
/// Resolves a place ID into full coordinates.
Future<ResolvedPlace> resolvePlace(String placeId);
}
/// Default geodesic straight-line preview implementation.
///
/// Truthfully connects stops with direct segments and calculates geodesic
/// distance without fabricating road paths or speculative turn ETAs.
class StraightLineRouteProvider implements RouteProvider {
const StraightLineRouteProvider();
@override
String get name => 'Straight-line Preview';
@override
Future<RouteCalculationResult> calculateRoute(
List<RouteStop> stops, {
bool avoidHighways = false,
bool avoidTolls = false,
bool avoidFerries = false,
RoutingOptions? options,
}) async {
final ordered = List<RouteStop>.from(stops);
ordered.sort((a, b) => a.order.compareTo(b.order));
final distanceMeters =
MotorSignalRoute.calculateTotalDistanceMeters(ordered);
final points = ordered.map((s) => s.latLng).toList();
return RouteCalculationResult(
straightLineDistanceMeters: distanceMeters,
estimatedDistanceMeters: null,
estimatedDurationSeconds: null,
geometry: points,
isStraightLine: true,
providerName: name,
);
}
@override
Future<List<PlaceSearchResult>> searchPlaces(
String query, {
AppLatLng? nearLocation,
}) async {
final clean = query.trim();
if (clean.isEmpty) return const [];
// Check if query is direct coordinates (lat, lng)
final parts = clean.split(RegExp(r'[,\s]+'));
if (parts.length == 2) {
final lat = double.tryParse(parts[0]);
final lng = double.tryParse(parts[1]);
if (lat != null &&
lng != null &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180) {
return [
PlaceSearchResult(
providerId: 'coord_${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}',
primaryText: 'Coordinate Pin',
secondaryText: '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}',
latitude: lat,
longitude: lng,
formattedAddress: '${lat.toStringAsFixed(5)}, ${lng.toStringAsFixed(5)}',
),
];
}
}
return [
PlaceSearchResult(
providerId: 'manual',
primaryText: clean,
secondaryText: 'Near Current Map Location',
latitude: nearLocation?.latitude,
longitude: nearLocation?.longitude,
),
];
}
@override
Future<ResolvedPlace> resolvePlace(String placeId) async {
return ResolvedPlace(
providerId: placeId,
displayName: 'Unknown Place',
latitude: 0,
longitude: 0,
);
}
}