From dfb8eceaade1e535f97d3d31cf0ad59a8e75daa1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 20:08:21 -0500 Subject: [PATCH] Basic routing. Still some bugs. --- lib/app_state.dart | 6 ++ lib/services/routing_service.dart | 164 ++++++++++++++++++++++++++++++ lib/state/navigation_state.dart | 46 +++++++-- lib/widgets/navigation_sheet.dart | 44 ++++++++ 4 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 lib/services/routing_service.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 6a18e6a..271f3c8 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -95,6 +95,8 @@ class AppState extends ChangeNotifier { bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint; bool get isCalculating => _navigationState.isCalculating; bool get showingOverview => _navigationState.showingOverview; + String? get routingError => _navigationState.routingError; + bool get hasRoutingError => _navigationState.hasRoutingError; // Navigation search state bool get isNavigationSearchLoading => _navigationState.isSearchLoading; @@ -340,6 +342,10 @@ class AppState extends ChangeNotifier { _navigationState.clearSearchResults(); } + void retryRouteCalculation() { + _navigationState.retryRouteCalculation(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart new file mode 100644 index 0000000..71afcc5 --- /dev/null +++ b/lib/services/routing_service.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +class RouteResult { + final List waypoints; + final double distanceMeters; + final double durationSeconds; + + const RouteResult({ + required this.waypoints, + required this.distanceMeters, + required this.durationSeconds, + }); + + @override + String toString() { + return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)'; + } +} + +class RoutingService { + static const String _baseUrl = 'https://router.project-osrm.org'; + static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; + static const Duration _timeout = Duration(seconds: 15); + + /// Calculate route between two points using OSRM + Future calculateRoute({ + required LatLng start, + required LatLng end, + String profile = 'driving', // driving, walking, cycling + }) async { + debugPrint('[RoutingService] Calculating route from $start to $end'); + + // OSRM uses lng,lat order (opposite of LatLng) + final startCoord = '${start.longitude},${start.latitude}'; + final endCoord = '${end.longitude},${end.latitude}'; + + final uri = Uri.parse('$_baseUrl/route/v1/$profile/$startCoord;$endCoord') + .replace(queryParameters: { + 'overview': 'full', // Get full geometry + 'geometries': 'polyline', // Use polyline encoding (more compact) + 'steps': 'false', // Don't need turn-by-turn for now + }); + + debugPrint('[RoutingService] OSRM request: $uri'); + + try { + final response = await http.get( + uri, + headers: { + 'User-Agent': _userAgent, + }, + ).timeout(_timeout); + + if (response.statusCode != 200) { + throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + } + + final data = json.decode(response.body) as Map; + + // Check OSRM response status + final code = data['code'] as String?; + if (code != 'Ok') { + final message = data['message'] as String? ?? 'Unknown routing error'; + throw RoutingException('OSRM error ($code): $message'); + } + + final routes = data['routes'] as List?; + if (routes == null || routes.isEmpty) { + throw RoutingException('No route found between these points'); + } + + final route = routes[0] as Map; + final geometry = route['geometry'] as String?; + final distance = (route['distance'] as num?)?.toDouble() ?? 0.0; + final duration = (route['duration'] as num?)?.toDouble() ?? 0.0; + + if (geometry == null) { + throw RoutingException('Route geometry missing from response'); + } + + // Decode polyline geometry to waypoints + final waypoints = _decodePolyline(geometry); + + if (waypoints.isEmpty) { + throw RoutingException('Failed to decode route geometry'); + } + + final result = RouteResult( + waypoints: waypoints, + distanceMeters: distance, + durationSeconds: duration, + ); + + debugPrint('[RoutingService] Route calculated: $result'); + return result; + + } catch (e) { + debugPrint('[RoutingService] Route calculation failed: $e'); + if (e is RoutingException) { + rethrow; + } else { + throw RoutingException('Network error: $e'); + } + } + } + + /// Decode OSRM polyline geometry to LatLng waypoints + List _decodePolyline(String encoded) { + try { + final List points = []; + int index = 0; + int lat = 0; + int lng = 0; + + while (index < encoded.length) { + int b; + int shift = 0; + int result = 0; + + // Decode latitude + do { + b = encoded.codeUnitAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + + final deltaLat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + lat += deltaLat; + + shift = 0; + result = 0; + + // Decode longitude + do { + b = encoded.codeUnitAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + + final deltaLng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + lng += deltaLng; + + points.add(LatLng(lat / 1E5, lng / 1E5)); + } + + return points; + } catch (e) { + debugPrint('[RoutingService] Manual polyline decoding failed: $e'); + return []; + } + } +} + +class RoutingException implements Exception { + final String message; + + const RoutingException(this.message); + + @override + String toString() => 'RoutingException: $message'; +} \ No newline at end of file diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index e5af71a..072fcf9 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -3,6 +3,7 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import '../services/search_service.dart'; +import '../services/routing_service.dart'; /// Simplified navigation modes - brutalist approach enum AppNavigationMode { @@ -14,6 +15,7 @@ enum AppNavigationMode { /// Simplified navigation state - fewer modes, clearer logic class NavigationState extends ChangeNotifier { final SearchService _searchService = SearchService(); + final RoutingService _routingService = RoutingService(); // Core state - just 3 modes AppNavigationMode _mode = AppNavigationMode.normal; @@ -22,6 +24,7 @@ class NavigationState extends ChangeNotifier { bool _isSettingSecondPoint = false; bool _isCalculating = false; bool _showingOverview = false; + String? _routingError; // Search state bool _isSearchLoading = false; @@ -46,6 +49,8 @@ class NavigationState extends ChangeNotifier { bool get isSettingSecondPoint => _isSettingSecondPoint; bool get isCalculating => _isCalculating; bool get showingOverview => _showingOverview; + String? get routingError => _routingError; + bool get hasRoutingError => _routingError != null; bool get isSearchLoading => _isSearchLoading; List get searchResults => List.unmodifiable(_searchResults); @@ -113,6 +118,7 @@ class NavigationState extends ChangeNotifier { _isCalculating = false; _showingOverview = false; _nextPointIsStart = false; + _routingError = null; // Clear search _clearSearchResults(); @@ -190,28 +196,52 @@ class NavigationState extends ChangeNotifier { } _isSettingSecondPoint = false; + _routingError = null; // Clear any previous errors _calculateRoute(); } - /// Calculate route + /// Retry route calculation (for error recovery) + void retryRouteCalculation() { + if (_routeStart == null || _routeEnd == null) return; + + debugPrint('[NavigationState] Retrying route calculation'); + _routingError = null; + _calculateRoute(); + } + + /// Calculate route using OSRM void _calculateRoute() { if (_routeStart == null || _routeEnd == null) return; - debugPrint('[NavigationState] Calculating route...'); + debugPrint('[NavigationState] Calculating route with OSRM...'); _isCalculating = true; + _routingError = null; notifyListeners(); - // Mock route calculation - Future.delayed(const Duration(seconds: 1), () { - if (!_isCalculating) return; // Canceled + _routingService.calculateRoute( + start: _routeStart!, + end: _routeEnd!, + profile: 'driving', // Could make this configurable later + ).then((routeResult) { + if (!_isCalculating) return; // Canceled while calculating - _routePath = [_routeStart!, _routeEnd!]; - _routeDistance = const Distance().as(LengthUnit.Meter, _routeStart!, _routeEnd!); + _routePath = routeResult.waypoints; + _routeDistance = routeResult.distanceMeters; _isCalculating = false; _showingOverview = true; _provisionalPinLocation = null; // Hide provisional pin - debugPrint('[NavigationState] Route calculated: ${(_routeDistance! / 1000).toStringAsFixed(1)} km'); + debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}'); + notifyListeners(); + + }).catchError((error) { + if (!_isCalculating) return; // Canceled while calculating + + debugPrint('[NavigationState] Route calculation failed: $error'); + _isCalculating = false; + _routingError = error.toString().replaceAll('RoutingException: ', ''); + + // Don't show overview on error, stay in current state notifyListeners(); }); } diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index 819b0de..a0ed8df 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -180,6 +180,50 @@ class NavigationSheet extends StatelessWidget { ), ], + // ROUTING ERROR: Show error with retry option + if (appState.hasRoutingError && !appState.isCalculating) ...[ + Icon( + Icons.error_outline, + size: 48, + color: Colors.red[400], + ), + const SizedBox(height: 16), + Text( + 'Route calculation failed', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + appState.routingError ?? 'Unknown error', + style: TextStyle(color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + onPressed: () { + // Retry route calculation + appState.retryRouteCalculation(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: const Text('Cancel'), + onPressed: () => appState.cancelNavigation(), + ), + ), + ], + ), + ], + // ROUTE OVERVIEW: Show route details with start/cancel options if (appState.showingOverview) ...[ if (appState.routeStart != null) ...[