Compare commits

..

6 Commits

Author SHA1 Message Date
stopflock
f78ea1a300 no dev mode - publishing 2026-02-14 14:50:38 -06:00
stopflock
2dca311d2a changelog, version 2026-02-14 14:42:39 -06:00
stopflock
7470b14e38 Merge pull request #113 from dougborg/fix/overpass-out-skel
Use out skel for Overpass way/relation pass
2026-02-14 14:30:08 -06:00
Doug Borg
5df0170344 Use out skel for Overpass way/relation pass and add service tests
Switch the second Overpass pass (ways/relations) from out meta to out skel,
dropping unused tags/version/changeset fields from the response. The app only
reads structural references (node lists, relation members) from these elements.

Also inject http.Client into OverpassService for testability (matching
RoutingService pattern) and add close() for client lifecycle management.

14 tests covering query building, constraint detection, and error handling.

Fixes #108

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:26:22 -07:00
stopflock
1429f1d02b Merge pull request #41 from dougborg/fix/map-jitter-on-touch
Fix map jitter/wiggle when touching at low zoom
2026-02-14 13:17:43 -06:00
Doug Borg
f0f23489b5 Fix map jitter when touching map during follow-me animations
Cancel in-progress follow-me animations on pointer-down and suppress
new ones while any pointer is on the map. Without this, GPS position
updates trigger 600ms animateTo() calls that fight with the user's
stationary finger, causing visible wiggle — especially at low zoom
where small geographic shifts cover more pixels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 02:02:51 -07:00
7 changed files with 377 additions and 37 deletions

View File

@@ -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": {

View File

@@ -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

View File

@@ -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;
''';
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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+)

View 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')));
});
});
}