mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Merge pull request #25 from tsbischof/navigation-using-alprwatch
Rework routing/navigation to use alprwatch API
This commit is contained in:
@@ -488,8 +488,7 @@ The major performance issue was discovered to be double caching with expensive o
|
||||
|
||||
**Current state:**
|
||||
- **Search functionality**: Fully implemented and active
|
||||
- **Basic routing**: Complete but disabled pending API integration
|
||||
- **Avoidance routing**: Awaiting alprwatch.org/directions API
|
||||
- **Avoidance routing**: Fully implemented and active
|
||||
- **Offline routing**: Requires vector map tiles
|
||||
|
||||
**Architecture:**
|
||||
@@ -848,4 +847,4 @@ debugPrint('[ComponentName] Detailed message: $data');
|
||||
|
||||
---
|
||||
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
|
||||
@@ -122,6 +122,7 @@ class AppState extends ChangeNotifier {
|
||||
// Navigation search state
|
||||
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
|
||||
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
|
||||
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
@@ -645,6 +646,11 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setSuspectedLocationMinDistance(distance);
|
||||
}
|
||||
|
||||
/// Set navigation avoidance distance
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
await _settingsState.setNavigationAvoidanceDistance(distance);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
|
||||
@@ -77,6 +77,7 @@ const bool kEnableNodeExtraction = false; // Set to true to enable extract from
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
return true;
|
||||
if (!kEnableDevelopmentModes) {
|
||||
return false; // Release builds: never allow navigation
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -26,48 +27,28 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Coming soon message
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Navigation Features',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Navigation and routing settings will be available here. Coming soon:\n\n'
|
||||
'• Surveillance avoidance distance\n'
|
||||
'• Route planning preferences\n'
|
||||
'• Search history management\n'
|
||||
'• Distance units (metric/imperial)',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Placeholder settings (disabled for now)
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.warning_outlined,
|
||||
title: locService.t('navigation.avoidanceDistance'),
|
||||
subtitle: locService.t('navigation.avoidanceDistanceSubtitle'),
|
||||
value: '100 ${locService.t('navigation.meters')}',
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('navigation.avoidanceDistance')),
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.navigationAvoidanceDistance.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 250;
|
||||
appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000));
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
@@ -126,4 +107,4 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
@@ -21,73 +24,86 @@ class RouteResult {
|
||||
}
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://router.project-osrm.org';
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
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
|
||||
// Calculate route between two points using alprwatch
|
||||
Future<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
String profile = 'driving', // driving, walking, cycling
|
||||
}) async {
|
||||
debugPrint('[RoutingService] Calculating route from $start to $end');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final avoidance_distance = await prefs.getInt('navigation_avoidance_distance');
|
||||
|
||||
final enabled_profiles = AppState.instance.enabledProfiles.map((p) {
|
||||
final full = p.toJson();
|
||||
return {
|
||||
'id': full['id'],
|
||||
'name': full['name'],
|
||||
'tags': full['tags'],
|
||||
};
|
||||
}).toList();
|
||||
|
||||
// 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');
|
||||
final params = {
|
||||
'start': {
|
||||
'longitude': start.longitude,
|
||||
'latitude': start.latitude
|
||||
},
|
||||
'end': {
|
||||
'longitude': end.longitude,
|
||||
'latitude': end.latitude
|
||||
},
|
||||
'avoidance_distance': avoidance_distance,
|
||||
'enabled_profiles': enabled_profiles,
|
||||
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
|
||||
};
|
||||
|
||||
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');
|
||||
debugPrint('[RoutingService] alprwatch request: $uri $params');
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: json.encode(params)
|
||||
).timeout(_timeout);
|
||||
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
||||
|
||||
// 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');
|
||||
// Check alprwatch response status
|
||||
final ok = data['ok'] as bool? ?? false;
|
||||
if ( ! ok ) {
|
||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('alprwatch error: $message');
|
||||
}
|
||||
|
||||
final routes = data['routes'] as List<dynamic>?;
|
||||
if (routes == null || routes.isEmpty) {
|
||||
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||
if (route == null) {
|
||||
throw RoutingException('No route found between these points');
|
||||
}
|
||||
|
||||
final route = routes[0] as Map<String, dynamic>;
|
||||
final geometry = route['geometry'] as String?;
|
||||
|
||||
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
if (pair.length != 2) return null;
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
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,
|
||||
@@ -106,52 +122,6 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode OSRM polyline geometry to LatLng waypoints
|
||||
List<LatLng> _decodePolyline(String encoded) {
|
||||
try {
|
||||
final List<LatLng> 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 {
|
||||
@@ -161,4 +131,4 @@ class RoutingException implements Exception {
|
||||
|
||||
@override
|
||||
String toString() => 'RoutingException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,11 +221,11 @@ class NavigationState extends ChangeNotifier {
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
/// Calculate route using OSRM
|
||||
/// Calculate route using alprwatch
|
||||
void _calculateRoute() {
|
||||
if (_routeStart == null || _routeEnd == null) return;
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with OSRM...');
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
||||
_isCalculating = true;
|
||||
_routingError = null;
|
||||
notifyListeners();
|
||||
@@ -233,7 +233,6 @@ class NavigationState extends ChangeNotifier {
|
||||
_routingService.calculateRoute(
|
||||
start: _routeStart!,
|
||||
end: _routeEnd!,
|
||||
profile: 'driving', // Could make this configurable later
|
||||
).then((routeResult) {
|
||||
if (!_isCalculating) return; // Canceled while calculating
|
||||
|
||||
@@ -243,7 +242,7 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = true;
|
||||
_provisionalPinLocation = null; // Hide provisional pin
|
||||
|
||||
debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}');
|
||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
||||
notifyListeners();
|
||||
|
||||
}).catchError((error) {
|
||||
@@ -348,4 +347,4 @@ class NavigationState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
||||
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
@@ -41,6 +42,7 @@ class SettingsState extends ChangeNotifier {
|
||||
int _suspectedLocationMinDistance = 100; // meters
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
int _navigationAvoidanceDistance = 250; // meters
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
@@ -54,6 +56,7 @@ class SettingsState extends ChangeNotifier {
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
@@ -100,6 +103,11 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
// Load max nodes
|
||||
_maxNodes = prefs.getInt(_maxNodesPrefsKey) ?? kDefaultMaxNodes;
|
||||
|
||||
// Load navigation avoidance distance
|
||||
if (prefs.containsKey(_navigationAvoidanceDistancePrefsKey)) {
|
||||
_navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load proximity alerts settings
|
||||
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
|
||||
@@ -351,4 +359,14 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Set distance for avoidance of nodes during navigation
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
if (_navigationAvoidanceDistance != distance) {
|
||||
_navigationAvoidanceDistance = distance;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_navigationAvoidanceDistancePrefsKey, distance);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -334,4 +334,4 @@ class NavigationSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user