mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-16 18:53:01 +00:00
Compare commits
6 Commits
v2.7.0-rc
...
v2.7.1-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f78ea1a300 | ||
|
|
2dca311d2a | ||
|
|
7470b14e38 | ||
|
|
5df0170344 | ||
|
|
1429f1d02b | ||
|
|
f0f23489b5 |
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"2.7.1": {
|
||||
"content": [
|
||||
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles"
|
||||
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles",
|
||||
"• Further improved node loading by only fetching what is needed to determine whether a node is attached to a way/relation"
|
||||
]
|
||||
},
|
||||
"2.6.4": {
|
||||
"content": [
|
||||
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
|
||||
"• Moved units setting from Navigation to Language & Region settings page",
|
||||
"• Search text now automatically clears when route planning starts"
|
||||
"• Moved units setting from Navigation to Language & Region settings page"
|
||||
]
|
||||
},
|
||||
"2.6.3": {
|
||||
|
||||
@@ -72,7 +72,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||
|
||||
@@ -12,7 +12,10 @@ import '../dev_config.dart';
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
OverpassService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
@@ -28,7 +31,7 @@ class OverpassService {
|
||||
try {
|
||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
||||
|
||||
final response = await http.post(
|
||||
final response = await _client.post(
|
||||
Uri.parse(_endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(kOverpassQueryTimeout);
|
||||
@@ -116,7 +119,7 @@ out body;
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
out skel;
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class GpsController {
|
||||
List<OsmNode> Function()? _getNearbyNodes;
|
||||
List<NodeProfile> Function()? _getEnabledProfiles;
|
||||
VoidCallback? _onMapMovedProgrammatically;
|
||||
bool Function()? _isUserInteracting;
|
||||
|
||||
/// Get the current GPS location (if available)
|
||||
LatLng? get currentLocation => _currentLocation;
|
||||
@@ -49,6 +50,7 @@ class GpsController {
|
||||
required List<OsmNode> Function() getNearbyNodes,
|
||||
required List<NodeProfile> Function() getEnabledProfiles,
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
bool Function()? isUserInteracting,
|
||||
}) async {
|
||||
debugPrint('[GpsController] Initializing GPS controller');
|
||||
|
||||
@@ -61,7 +63,8 @@ class GpsController {
|
||||
_getNearbyNodes = getNearbyNodes;
|
||||
_getEnabledProfiles = getEnabledProfiles;
|
||||
_onMapMovedProgrammatically = onMapMovedProgrammatically;
|
||||
|
||||
_isUserInteracting = isUserInteracting;
|
||||
|
||||
// Start location tracking
|
||||
await _startLocationTracking();
|
||||
}
|
||||
@@ -235,9 +238,10 @@ class GpsController {
|
||||
if (followMeMode == FollowMeMode.off || _mapController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
if (_isUserInteracting?.call() == true) return;
|
||||
|
||||
if (followMeMode == FollowMeMode.follow) {
|
||||
// Follow position, preserve rotation
|
||||
_mapController!.animateTo(
|
||||
@@ -352,5 +356,6 @@ class GpsController {
|
||||
_getNearbyNodes = null;
|
||||
_getEnabledProfiles = null;
|
||||
_onMapMovedProgrammatically = null;
|
||||
_isUserInteracting = null;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
|
||||
// Track active pointers to suppress follow-me animations during touch
|
||||
int _activePointers = 0;
|
||||
|
||||
|
||||
|
||||
@@ -189,6 +192,7 @@ class MapViewState extends State<MapView> {
|
||||
// Refresh nodes when GPS controller moves the map
|
||||
_refreshNodesFromProvider();
|
||||
},
|
||||
isUserInteracting: () => _activePointers > 0,
|
||||
);
|
||||
|
||||
// Fetch initial cameras
|
||||
@@ -380,10 +384,21 @@ class MapViewState extends State<MapView> {
|
||||
children: [
|
||||
SheetAwareMap(
|
||||
sheetHeight: widget.sheetHeight,
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
child: Listener(
|
||||
onPointerDown: (_) {
|
||||
_activePointers++;
|
||||
_controller.stopAnimations();
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
if (_activePointers > 0) _activePointers--;
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
if (_activePointers > 0) _activePointers--;
|
||||
},
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
minZoom: 1.0,
|
||||
@@ -486,30 +501,31 @@ class MapViewState extends State<MapView> {
|
||||
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
_tileManager.buildTileLayer(
|
||||
selectedProvider: appState.selectedTileProvider,
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Custom scale bar that respects user's distance unit preference
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return CustomScaleBar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
|
||||
),
|
||||
maxWidthPx: 120,
|
||||
barHeight: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_tileManager.buildTileLayer(
|
||||
selectedProvider: appState.selectedTileProvider,
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Custom scale bar that respects user's distance unit preference
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return CustomScaleBar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
|
||||
),
|
||||
maxWidthPx: 120,
|
||||
barHeight: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// All map overlays (mode indicator, zoom, attribution, add pin)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.7.0+47 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.7.1+47 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+)
|
||||
|
||||
316
test/services/overpass_service_test.dart
Normal file
316
test/services/overpass_service_test.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/services/overpass_service.dart';
|
||||
|
||||
class MockHttpClient extends Mock implements http.Client {}
|
||||
|
||||
void main() {
|
||||
late MockHttpClient mockClient;
|
||||
late OverpassService service;
|
||||
|
||||
final bounds = LatLngBounds(
|
||||
const LatLng(38.8, -77.1),
|
||||
const LatLng(39.0, -76.9),
|
||||
);
|
||||
|
||||
final profiles = [
|
||||
NodeProfile(
|
||||
id: 'test-alpr',
|
||||
name: 'Test ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Uri.parse('https://example.com'));
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockClient = MockHttpClient();
|
||||
service = OverpassService(client: mockClient);
|
||||
});
|
||||
|
||||
/// Helper: stub a successful Overpass response with the given elements.
|
||||
void stubOverpassResponse(List<Map<String, dynamic>> elements) {
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((_) async => http.Response(
|
||||
jsonEncode({'elements': elements}),
|
||||
200,
|
||||
));
|
||||
}
|
||||
|
||||
/// Helper: stub an error response.
|
||||
void stubErrorResponse(int statusCode, String body) {
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((_) async => http.Response(body, statusCode));
|
||||
}
|
||||
|
||||
group('query building', () {
|
||||
test('uses out skel for way/relation pass, out body for node pass',
|
||||
() async {
|
||||
stubOverpassResponse([]);
|
||||
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
final captured = verify(
|
||||
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||
).captured;
|
||||
|
||||
final query = (captured.last as Map<String, String>)['data']!;
|
||||
expect(query, contains('out body;'));
|
||||
expect(query, contains('out skel;'));
|
||||
expect(query, isNot(contains('out meta;')));
|
||||
});
|
||||
|
||||
test('empty tag values are excluded from filters', () async {
|
||||
final profileWithEmpty = [
|
||||
NodeProfile(
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'camera:mount': '', // empty — should be excluded
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
stubOverpassResponse([]);
|
||||
|
||||
await service.fetchNodes(bounds: bounds, profiles: profileWithEmpty);
|
||||
|
||||
final captured = verify(
|
||||
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||
).captured;
|
||||
|
||||
final query = (captured.last as Map<String, String>)['data']!;
|
||||
expect(query, contains('["man_made"="surveillance"]'));
|
||||
expect(query, isNot(contains('camera:mount')));
|
||||
});
|
||||
});
|
||||
|
||||
group('response parsing — constraint detection', () {
|
||||
test('nodes referenced by a way are constrained', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 1,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 2,
|
||||
'lat': 38.91,
|
||||
'lon': -77.01,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'id': 100,
|
||||
'nodes': [1],
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(2));
|
||||
final node1 = nodes.firstWhere((n) => n.id == 1);
|
||||
final node2 = nodes.firstWhere((n) => n.id == 2);
|
||||
expect(node1.isConstrained, isTrue);
|
||||
expect(node2.isConstrained, isFalse);
|
||||
});
|
||||
|
||||
test('nodes referenced by a relation member are constrained', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 3,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'relation',
|
||||
'id': 200,
|
||||
'members': [
|
||||
{'type': 'node', 'ref': 3, 'role': ''},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(nodes.first.isConstrained, isTrue);
|
||||
});
|
||||
|
||||
test('nodes not in any way or relation are unconstrained', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 4,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(nodes.first.isConstrained, isFalse);
|
||||
});
|
||||
|
||||
test('mixed response with nodes, ways, and relations', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 10,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 11,
|
||||
'lat': 38.91,
|
||||
'lon': -77.01,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 12,
|
||||
'lat': 38.92,
|
||||
'lon': -77.02,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'id': 300,
|
||||
'nodes': [10],
|
||||
},
|
||||
{
|
||||
'type': 'relation',
|
||||
'id': 400,
|
||||
'members': [
|
||||
{'type': 'node', 'ref': 11, 'role': ''},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(3));
|
||||
expect(nodes.firstWhere((n) => n.id == 10).isConstrained, isTrue);
|
||||
expect(nodes.firstWhere((n) => n.id == 11).isConstrained, isTrue);
|
||||
expect(nodes.firstWhere((n) => n.id == 12).isConstrained, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('error handling', () {
|
||||
test('HTTP 200 returns parsed nodes', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 1,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(nodes.first.id, equals(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'HTTP 400 with "too many nodes" and "50000" throws NodeLimitError',
|
||||
() async {
|
||||
stubErrorResponse(
|
||||
400, 'Error: too many nodes (limit is 50000) in query');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('response with "timeout" throws NodeLimitError', () async {
|
||||
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('response with "runtime limit exceeded" throws NodeLimitError',
|
||||
() async {
|
||||
stubErrorResponse(400, 'runtime limit exceeded');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('HTTP 429 throws RateLimitError', () async {
|
||||
stubErrorResponse(429, 'Too Many Requests');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<RateLimitError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('response with "rate limited" throws RateLimitError', () async {
|
||||
stubErrorResponse(503, 'You are rate limited');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<RateLimitError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('other HTTP errors with retries exhausted throws NetworkError',
|
||||
() async {
|
||||
stubErrorResponse(500, 'Internal Server Error');
|
||||
|
||||
expect(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
throwsA(isA<NetworkError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('empty profiles returns empty list without making request',
|
||||
() async {
|
||||
final nodes = await service.fetchNodes(bounds: bounds, profiles: []);
|
||||
|
||||
expect(nodes, isEmpty);
|
||||
verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user