From 3c996c78c91492892582a92c5ac97e130170e043 Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 21 Nov 2025 15:35:12 -0600 Subject: [PATCH] Two nodes too close together warning --- README.md | 4 +- assets/changelog.json | 4 + lib/app_state.dart | 2 + lib/dev_config.dart | 3 + lib/localizations/de.json | 18 ++++ lib/localizations/en.json | 18 ++++ lib/localizations/es.json | 18 ++++ lib/localizations/fr.json | 18 ++++ lib/localizations/it.json | 18 ++++ lib/localizations/pt.json | 18 ++++ lib/localizations/zh.json | 18 ++++ lib/services/node_cache.dart | 30 ++++++ lib/widgets/add_node_sheet.dart | 51 ++++++++- lib/widgets/edit_node_sheet.dart | 46 +++++++- lib/widgets/proximity_warning_dialog.dart | 126 ++++++++++++++++++++++ 15 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 lib/widgets/proximity_warning_dialog.dart diff --git a/README.md b/README.md index edfacbe..427e3ab 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,13 @@ cp lib/keys.dart.example lib/keys.dart ## Roadmap ### Needed Bugfixes +- Update node cache to reflect cleared queue entries - Decide what to do for extracting nodes attached to a way/relation: - Auto extract (how?) - Leave it alone (wrong answer unless user chooses intentionally) - Manual cleanup (cognitive load for users) - Delete the old one (also wrong answer unless user chooses intentionally) - Give multiple of these options?? -- Two nodes too close together warning - Nav start+end too close together error (warning + disable submit button?) - Improve/retune tile fetching backoff/retry - Disable deletes on nodes belonging to ways/relations @@ -115,7 +115,7 @@ cp lib/keys.dart.example lib/keys.dart - Fix network indicator - only done when fetch queue is empty! ### Current Development -- Persistent cache for MY submissions: clean up when we see that node appear in overpass/OSM results or when older than 24h +- Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h - Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?) - Tutorial / info guide before submitting first node, info and links before creating first profile - Option to pull in profiles from NSI (man_made=surveillance only?) diff --git a/assets/changelog.json b/assets/changelog.json index 4f6d570..a40d525 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,12 +1,16 @@ { "1.4.2": { "content": [ + "• NEW: Proximity warning when placing nodes too close together - prevents accidental duplicate submissions", + "• NEW: Configurable distance threshold (5 meters default) warns when nodes are too close to existing devices", + "• NEW: Smart warning dialog suggests using multiple directions on single nodes instead of separate nearby nodes", "• NEW: Dedicated 'Upload Queue' page - queue items are now shown in a proper list view instead of a popup", "• NEW: 'Clear Upload Queue' button is always visible at the top of queue page, greyed out when empty", "• NEW: 'OpenStreetMap Account' page for managing OSM login and account settings", "• NEW: 'View My Edits on OSM' button takes you directly to your edit history on OpenStreetMap", "• IMPROVED: Settings page organization with dedicated pages for upload management and OSM account", "• IMPROVED: Better empty queue state with helpful messaging", + "• UX: Proximity warnings help maintain data quality by preventing common mapping errors", "• UX: Cleaner settings page layout with auth and queue sections moved to their own dedicated pages", "• UX: Added informational content about OpenStreetMap on the account page" ] diff --git a/lib/app_state.dart b/lib/app_state.dart index 5a77702..3192cb3 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -17,6 +17,8 @@ import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; import 'services/profile_service.dart'; import 'widgets/camera_provider_with_cache.dart'; +import 'widgets/proximity_warning_dialog.dart'; +import 'dev_config.dart'; import 'state/auth_state.dart'; import 'state/navigation_state.dart'; import 'state/operator_profile_state.dart'; diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 76a6e05..7549dbe 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -112,6 +112,9 @@ const int kProximityAlertMinDistance = 50; // meters const int kProximityAlertMaxDistance = 1600; // meters const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node +// Node proximity warning configuration (for new/edited nodes that are too close to existing ones) +const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning + // Map interaction configuration const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0) const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005) diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 2c7d3dc..01a4c8e 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -21,6 +21,24 @@ "advanced": "Erweitert", "useAdvancedEditor": "Erweiterten Editor verwenden" }, + "proximityWarning": { + "title": "Knoten sehr nah an vorhandenem Gerät", + "message": "Dieser Knoten ist nur {} Meter von einem vorhandenen Überwachungsgerät entfernt.", + "suggestion": "Wenn mehrere Geräte am selben Mast sind, verwenden Sie bitte mehrere Richtungen auf einem einzigen Knoten, anstatt separate Knoten zu erstellen.", + "nearbyNodes": "Nahegelegene Gerät(e) gefunden ({}):", + "nodeInfo": "Knoten #{} - {}", + "andMore": "...und {} weitere", + "goBack": "Zurück", + "submitAnyway": "Trotzdem senden", + "nodeType": { + "alpr": "ALPR/ANPR Kamera", + "publicCamera": "Öffentliche Überwachungskamera", + "camera": "Überwachungskamera", + "amenity": "{}", + "device": "{} Gerät", + "unknown": "Unbekanntes Gerät" + } + }, "followMe": { "off": "Verfolgung aktivieren", "follow": "Verfolgung aktivieren (Rotation)", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index db2f5bd..56779dd 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -39,6 +39,24 @@ "advanced": "Advanced", "useAdvancedEditor": "Use Advanced Editor" }, + "proximityWarning": { + "title": "Node Very Close to Existing Device", + "message": "This node is only {} meters from an existing surveillance device.", + "suggestion": "If multiple devices are on the same pole, please use multiple directions on a single node instead of creating separate nodes.", + "nearbyNodes": "Nearby device(s) found ({}):", + "nodeInfo": "Node #{} - {}", + "andMore": "...and {} more", + "goBack": "Go Back", + "submitAnyway": "Submit Anyway", + "nodeType": { + "alpr": "ALPR/ANPR Camera", + "publicCamera": "Public Surveillance Camera", + "camera": "Surveillance Camera", + "amenity": "{}", + "device": "{} Device", + "unknown": "Unknown Device" + } + }, "followMe": { "off": "Enable follow-me", "follow": "Enable follow-me (rotating)", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index e8b18fb..94d66c9 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -39,6 +39,24 @@ "advanced": "Avanzado", "useAdvancedEditor": "Usar Editor Avanzado" }, + "proximityWarning": { + "title": "Nodo Muy Cerca de Dispositivo Existente", + "message": "Este nodo está a solo {} metros de un dispositivo de vigilancia existente.", + "suggestion": "Si hay múltiples dispositivos en el mismo poste, use múltiples direcciones en un solo nodo en lugar de crear nodos separados.", + "nearbyNodes": "Dispositivo(s) cercano(s) encontrado(s) ({}):", + "nodeInfo": "Nodo #{} - {}", + "andMore": "...y {} más", + "goBack": "Volver", + "submitAnyway": "Enviar de Todas Formas", + "nodeType": { + "alpr": "Cámara ALPR/ANPR", + "publicCamera": "Cámara de Vigilancia Pública", + "camera": "Cámara de Vigilancia", + "amenity": "{}", + "device": "Dispositivo {}", + "unknown": "Dispositivo Desconocido" + } + }, "followMe": { "off": "Activar seguimiento", "follow": "Activar seguimiento (rotación)", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 64b22f8..b1a8700 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -39,6 +39,24 @@ "advanced": "Avancé", "useAdvancedEditor": "Utiliser l'Éditeur Avancé" }, + "proximityWarning": { + "title": "Nœud Très Proche d'un Dispositif Existant", + "message": "Ce nœud n'est qu'à {} mètres d'un dispositif de surveillance existant.", + "suggestion": "Si plusieurs dispositifs se trouvent sur le même poteau, veuillez utiliser plusieurs directions sur un seul nœud au lieu de créer des nœuds séparés.", + "nearbyNodes": "Dispositif(s) proche(s) trouvé(s) ({}) :", + "nodeInfo": "Nœud #{} - {}", + "andMore": "...et {} de plus", + "goBack": "Retour", + "submitAnyway": "Soumettre Quand Même", + "nodeType": { + "alpr": "Caméra ALPR/ANPR", + "publicCamera": "Caméra de Surveillance Publique", + "camera": "Caméra de Surveillance", + "amenity": "{}", + "device": "Dispositif {}", + "unknown": "Dispositif Inconnu" + } + }, "followMe": { "off": "Activer le suivi", "follow": "Activer le suivi (rotation)", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index d24c8aa..aca312e 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -39,6 +39,24 @@ "advanced": "Avanzato", "useAdvancedEditor": "Usa Editor Avanzato" }, + "proximityWarning": { + "title": "Nodo Molto Vicino a Dispositivo Esistente", + "message": "Questo nodo è a soli {} metri da un dispositivo di sorveglianza esistente.", + "suggestion": "Se ci sono più dispositivi sullo stesso palo, utilizzare più direzioni su un singolo nodo invece di creare nodi separati.", + "nearbyNodes": "Dispositivo/i vicino/i trovato/i ({}):", + "nodeInfo": "Nodo #{} - {}", + "andMore": "...e altri {}", + "goBack": "Torna Indietro", + "submitAnyway": "Invia Comunque", + "nodeType": { + "alpr": "Telecamera ALPR/ANPR", + "publicCamera": "Telecamera di Sorveglianza Pubblica", + "camera": "Telecamera di Sorveglianza", + "amenity": "{}", + "device": "Dispositivo {}", + "unknown": "Dispositivo Sconosciuto" + } + }, "followMe": { "off": "Attiva seguimi", "follow": "Attiva seguimi (rotazione)", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index bfe672a..85f2a5b 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -39,6 +39,24 @@ "advanced": "Avançado", "useAdvancedEditor": "Usar Editor Avançado" }, + "proximityWarning": { + "title": "Nó Muito Próximo de Dispositivo Existente", + "message": "Este nó está a apenas {} metros de um dispositivo de vigilância existente.", + "suggestion": "Se vários dispositivos estão no mesmo poste, use várias direções em um único nó em vez de criar nós separados.", + "nearbyNodes": "Dispositivo(s) próximo(s) encontrado(s) ({}):", + "nodeInfo": "Nó #{} - {}", + "andMore": "...e mais {}", + "goBack": "Voltar", + "submitAnyway": "Enviar Mesmo Assim", + "nodeType": { + "alpr": "Câmera ALPR/ANPR", + "publicCamera": "Câmera de Vigilância Pública", + "camera": "Câmera de Vigilância", + "amenity": "{}", + "device": "Dispositivo {}", + "unknown": "Dispositivo Desconhecido" + } + }, "followMe": { "off": "Ativar seguir-me", "follow": "Ativar seguir-me (rotação)", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 5f7243e..d127911 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -39,6 +39,24 @@ "advanced": "高级", "useAdvancedEditor": "使用高级编辑器" }, + "proximityWarning": { + "title": "节点过于靠近现有设备", + "message": "此节点距离现有监控设备仅 {} 米。", + "suggestion": "如果同一根杆上有多个设备,请在单个节点上使用多个方向,而不是创建单独的节点。", + "nearbyNodes": "发现附近设备 ({}):", + "nodeInfo": "节点 #{} - {}", + "andMore": "...还有 {} 个", + "goBack": "返回", + "submitAnyway": "仍然提交", + "nodeType": { + "alpr": "ALPR/ANPR 摄像头", + "publicCamera": "公共监控摄像头", + "camera": "监控摄像头", + "amenity": "{}", + "device": "{} 设备", + "unknown": "未知设备" + } + }, "followMe": { "off": "启用跟随模式", "follow": "启用跟随模式(旋转)", diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 8e1f45f..6dc2846 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -2,6 +2,8 @@ import 'package:latlong2/latlong.dart'; import '../models/osm_node.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +const Distance _distance = Distance(); + class NodeCache { // Singleton instance static final NodeCache instance = NodeCache._internal(); @@ -103,6 +105,34 @@ class NodeCache { (coord1.longitude - coord2.longitude).abs() < tolerance; } + /// Find nodes within the specified distance (in meters) of the given coordinate + /// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes) + List findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) { + final nearbyNodes = []; + + for (final node in _nodes.values) { + // Skip the excluded node (typically the node being edited) + if (excludeNodeId != null && node.id == excludeNodeId) { + continue; + } + + // Skip temporary nodes (negative IDs) with pending upload/edit/deletion markers + if (node.id < 0 && ( + node.tags.containsKey('_pending_upload') || + node.tags.containsKey('_pending_edit') || + node.tags.containsKey('_pending_deletion'))) { + continue; + } + + final distance = _distance.as(LengthUnit.Meter, coord, node.coord); + if (distance <= distanceMeters) { + nearbyNodes.add(node); + } + } + + return nearbyNodes; + } + /// Utility: point-in-bounds for coordinates bool _inBounds(LatLng coord, LatLngBounds bounds) { return coord.latitude >= bounds.southWest.latitude && diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 58ba75c..3a15673 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -6,13 +6,58 @@ import '../dev_config.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; import '../services/localization_service.dart'; +import '../services/node_cache.dart'; import 'refine_tags_sheet.dart'; +import 'proximity_warning_dialog.dart'; class AddNodeSheet extends StatelessWidget { const AddNodeSheet({super.key, required this.session}); final AddNodeSession session; + void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) { + // Only check proximity if we have a target location + if (session.target == null) { + _commitWithoutCheck(context, appState, locService); + return; + } + + // Check for nearby nodes within the configured distance + final nearbyNodes = NodeCache.instance.findNodesWithinDistance( + session.target!, + kNodeProximityWarningDistance, + ); + + if (nearbyNodes.isNotEmpty) { + // Show proximity warning dialog + showDialog( + context: context, + builder: (context) => ProximityWarningDialog( + nearbyNodes: nearbyNodes, + distance: kNodeProximityWarningDistance, + onGoBack: () { + Navigator.of(context).pop(); // Close dialog + }, + onSubmitAnyway: () { + Navigator.of(context).pop(); // Close dialog + _commitWithoutCheck(context, appState, locService); + }, + ), + ); + } else { + // No nearby nodes, proceed with commit + _commitWithoutCheck(context, appState, locService); + } + } + + void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) { + appState.commitSession(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('node.queuedForUpload'))), + ); + } + Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; @@ -144,11 +189,7 @@ class AddNodeSheet extends StatelessWidget { final appState = context.watch(); void _commit() { - appState.commitSession(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('node.queuedForUpload'))), - ); + _checkProximityAndCommit(context, appState, locService); } void _cancel() { diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index ad7277a..4a46cbc 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -6,15 +6,55 @@ import '../dev_config.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; import '../services/localization_service.dart'; +import '../services/node_cache.dart'; import '../state/settings_state.dart'; import 'refine_tags_sheet.dart'; import 'advanced_edit_options_sheet.dart'; +import 'proximity_warning_dialog.dart'; class EditNodeSheet extends StatelessWidget { const EditNodeSheet({super.key, required this.session}); final EditNodeSession session; + void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) { + // Check for nearby nodes within the configured distance, excluding the node being edited + final nearbyNodes = NodeCache.instance.findNodesWithinDistance( + session.target, + kNodeProximityWarningDistance, + excludeNodeId: session.originalNode.id, + ); + + if (nearbyNodes.isNotEmpty) { + // Show proximity warning dialog + showDialog( + context: context, + builder: (context) => ProximityWarningDialog( + nearbyNodes: nearbyNodes, + distance: kNodeProximityWarningDistance, + onGoBack: () { + Navigator.of(context).pop(); // Close dialog + }, + onSubmitAnyway: () { + Navigator.of(context).pop(); // Close dialog + _commitWithoutCheck(context, appState, locService); + }, + ), + ); + } else { + // No nearby nodes, proceed with commit + _commitWithoutCheck(context, appState, locService); + } + } + + void _commitWithoutCheck(BuildContext context, AppState appState, LocalizationService locService) { + appState.commitEditSession(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('node.editQueuedForUpload'))), + ); + } + Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; @@ -146,11 +186,7 @@ class EditNodeSheet extends StatelessWidget { final appState = context.watch(); void _commit() { - appState.commitEditSession(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(locService.t('node.editQueuedForUpload'))), - ); + _checkProximityAndCommit(context, appState, locService); } void _cancel() { diff --git a/lib/widgets/proximity_warning_dialog.dart b/lib/widgets/proximity_warning_dialog.dart new file mode 100644 index 0000000..81402f8 --- /dev/null +++ b/lib/widgets/proximity_warning_dialog.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/osm_node.dart'; +import '../services/localization_service.dart'; + +class ProximityWarningDialog extends StatelessWidget { + final List nearbyNodes; + final double distance; + final VoidCallback onGoBack; + final VoidCallback onSubmitAnyway; + + const ProximityWarningDialog({ + super.key, + required this.nearbyNodes, + required this.distance, + required this.onGoBack, + required this.onSubmitAnyway, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + + return AlertDialog( + icon: const Icon( + Icons.warning_amber_rounded, + color: Colors.orange, + size: 32, + ), + title: Text(locService.t('proximityWarning.title')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('proximityWarning.message', + params: [distance.toStringAsFixed(1)]), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Text( + locService.t('proximityWarning.suggestion'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 16), + Text( + locService.t('proximityWarning.nearbyNodes', + params: [nearbyNodes.length.toString()]), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ...nearbyNodes.take(3).map((node) => Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 4.0), + child: Text( + '• ${locService.t('proximityWarning.nodeInfo', params: [ + node.id.toString(), + _getNodeTypeDescription(node, locService), + ])}', + style: Theme.of(context).textTheme.bodySmall, + ), + )), + if (nearbyNodes.length > 3) + Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0), + child: Text( + locService.t('proximityWarning.andMore', + params: [(nearbyNodes.length - 3).toString()]), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: onGoBack, + child: Text(locService.t('proximityWarning.goBack')), + ), + ElevatedButton( + onPressed: onSubmitAnyway, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + child: Text(locService.t('proximityWarning.submitAnyway')), + ), + ], + ); + }, + ); + } + + String _getNodeTypeDescription(OsmNode node, LocalizationService locService) { + // Try to get a meaningful description from the node's tags + final manMade = node.tags['man_made']; + final amenity = node.tags['amenity']; + final surveillance = node.tags['surveillance']; + final surveillanceType = node.tags['surveillance:type']; + final manufacturer = node.tags['manufacturer']; + + if (manMade == 'surveillance') { + if (surveillanceType == 'ALPR' || surveillanceType == 'ANPR') { + return locService.t('proximityWarning.nodeType.alpr'); + } else if (surveillance == 'public') { + return locService.t('proximityWarning.nodeType.publicCamera'); + } else { + return locService.t('proximityWarning.nodeType.camera'); + } + } else if (amenity != null) { + return locService.t('proximityWarning.nodeType.amenity', params: [amenity]); + } else if (manufacturer != null) { + return locService.t('proximityWarning.nodeType.device', params: [manufacturer]); + } else { + return locService.t('proximityWarning.nodeType.unknown'); + } + } +} \ No newline at end of file