mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
5 Commits
v1.3.2-rel
...
v1.3.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea6d6b2ff | ||
|
|
326b7ec523 | ||
|
|
192c6e5158 | ||
|
|
ac53f7f74e | ||
|
|
5b9810b9de |
12
README.md
12
README.md
@@ -102,12 +102,22 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
- Fix network indicator - only done when fetch queue is empty!
|
||||
|
||||
### Current Development
|
||||
- Add some builtin satellite tile provider
|
||||
- Option to pull in profiles from NSI (man_made=surveillance only)
|
||||
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
|
||||
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
|
||||
- Tutorial / info guide before submitting first node
|
||||
- Link to OSM node in node_details_sheet
|
||||
- Link to "my changes" on osm (username edit history)
|
||||
- Option to "extract node from way" for nodes attached to a way to allow moving
|
||||
- Option to "open in other editor" for advanced edits: StreetComplete/EveryDoor/Vespucci/GO!! Map/OSM.org(iD)/Rapid/Level0/OSMand/OrganicMaps/CoMaps
|
||||
|
||||
### On Pause
|
||||
- Suspected locations expansion to more regions
|
||||
- Import/Export map providers
|
||||
- Swap in alprwatch.org/directions avoidance routing API
|
||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||
- Improve offline area node refresh live display
|
||||
- Add Rekor profile
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Update offline area nodes while browsing?
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"1.3.3": {
|
||||
"content": "• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras"
|
||||
},
|
||||
"1.3.2": {
|
||||
"content": "• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved\n• UX: Fixed Android navigation bar covering settings page content"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simu
|
||||
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
|
||||
|
||||
// Node editing features - set to false to temporarily disable editing
|
||||
const bool kEnableNodeEdits = false; // Set to false to temporarily disable node editing
|
||||
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einer Straße oder einem Bereich verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
@@ -307,7 +308,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen"
|
||||
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
|
||||
"loading": "Lädt...",
|
||||
"timedOut": "Zeitüberschreitung",
|
||||
"noData": "Keine Kacheln hier",
|
||||
"success": "Fertig",
|
||||
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
|
||||
"tileProviderSlow": "Kartenanbieter langsam",
|
||||
"nodeDataSlow": "Knotendaten langsam",
|
||||
"networkIssues": "Netzwerkprobleme"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Überwachungs-Transparenz",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to a street or area (OSM way/relation). You can still edit its tags and direction.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Show network status indicator",
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map"
|
||||
"showIndicatorSubtitle": "Display network loading and error status on the map",
|
||||
"loading": "Loading...",
|
||||
"timedOut": "Timed out",
|
||||
"noData": "No tiles here",
|
||||
"success": "Done",
|
||||
"nodeLimitReached": "Showing limit - increase in settings",
|
||||
"tileProviderSlow": "Tile provider slow",
|
||||
"nodeDataSlow": "Node data slow",
|
||||
"networkIssues": "Network issues"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Search Location",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a una calle o área (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostrar indicador de estado de red",
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa"
|
||||
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
|
||||
"loading": "Cargando...",
|
||||
"timedOut": "Tiempo agotado",
|
||||
"noData": "Sin mosaicos aquí",
|
||||
"success": "Hecho",
|
||||
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
|
||||
"tileProviderSlow": "Proveedor de mosaicos lento",
|
||||
"nodeDataSlow": "Datos de nodo lentos",
|
||||
"networkIssues": "Problemas de red"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar ubicación",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à une rue ou une zone (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Afficher l'indicateur de statut réseau",
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte"
|
||||
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
|
||||
"loading": "Chargement...",
|
||||
"timedOut": "Temps dépassé",
|
||||
"noData": "Aucune tuile ici",
|
||||
"success": "Terminé",
|
||||
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
|
||||
"tileProviderSlow": "Fournisseur de tuiles lent",
|
||||
"nodeDataSlow": "Données de nœud lentes",
|
||||
"networkIssues": "Problèmes réseau"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Rechercher lieu",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a una strada o area (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Mostra indicatore di stato di rete",
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa"
|
||||
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
|
||||
"loading": "Caricamento...",
|
||||
"timedOut": "Tempo scaduto",
|
||||
"noData": "Nessuna tessera qui",
|
||||
"success": "Fatto",
|
||||
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
|
||||
"tileProviderSlow": "Provider di tessere lento",
|
||||
"nodeDataSlow": "Dati del nodo lenti",
|
||||
"networkIssues": "Problemi di rete"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Cerca posizione",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a uma rua ou área (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Exibir indicador de status de rede",
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa"
|
||||
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
|
||||
"loading": "Carregando...",
|
||||
"timedOut": "Tempo esgotado",
|
||||
"noData": "Nenhum tile aqui",
|
||||
"success": "Concluído",
|
||||
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
|
||||
"tileProviderSlow": "Provedor de tiles lento",
|
||||
"nodeDataSlow": "Dados do nó lentos",
|
||||
"networkIssues": "Problemas de rede"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Buscar localização",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到街道或区域(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
@@ -325,7 +326,15 @@
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "显示网络状态指示器",
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态"
|
||||
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
|
||||
"loading": "加载中...",
|
||||
"timedOut": "超时",
|
||||
"noData": "这里没有瓦片",
|
||||
"success": "完成",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"tileProviderSlow": "瓦片提供商缓慢",
|
||||
"nodeDataSlow": "节点数据缓慢",
|
||||
"networkIssues": "网络问题"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
|
||||
@@ -118,6 +118,39 @@ class NodeProfile {
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-rekor',
|
||||
name: 'Rekor',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Rekor',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-axis',
|
||||
name: 'Axis Communications',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Axis Communications',
|
||||
'manufacturer:wikidata': 'Q2347731',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
),
|
||||
NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
|
||||
@@ -4,11 +4,13 @@ class OsmNode {
|
||||
final int id;
|
||||
final LatLng coord;
|
||||
final Map<String, String> tags;
|
||||
final bool isConstrained; // true if part of any way/relation
|
||||
|
||||
OsmNode({
|
||||
required this.id,
|
||||
required this.coord,
|
||||
required this.tags,
|
||||
this.isConstrained = false, // Default to unconstrained for backward compatibility
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -16,6 +18,7 @@ class OsmNode {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'tags': tags,
|
||||
'isConstrained': isConstrained,
|
||||
};
|
||||
|
||||
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||
@@ -29,6 +32,7 @@ class OsmNode {
|
||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||
tags: tags,
|
||||
isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Center map on the node being edited (same animation as openNodeTagSheet)
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: session.originalNode.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Controller really not ready, skip centering
|
||||
}
|
||||
}
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
@@ -152,8 +171,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
if (_tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Small delay to let tag sheet close smoothly
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
|
||||
@@ -47,44 +47,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = <OsmNode>[];
|
||||
|
||||
// Find all node elements
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
nodes.add(OsmNode(
|
||||
id: id,
|
||||
coord: LatLng(lat, lon),
|
||||
tags: tags,
|
||||
));
|
||||
}
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
@@ -107,6 +70,93 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OSM API XML response to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOsmApiResponseWithConstraints(XmlDocument document, List<NodeProfile> profiles, int maxResults) {
|
||||
final surveillanceNodes = <int, Map<String, dynamic>>{}; // nodeId -> node data
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
|
||||
final latStr = nodeElement.getAttribute('lat');
|
||||
final lonStr = nodeElement.getAttribute('lon');
|
||||
|
||||
if (id == null || latStr == null || lonStr == null) continue;
|
||||
|
||||
final lat = double.tryParse(latStr);
|
||||
final lon = double.tryParse(lonStr);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
// Parse tags
|
||||
final tags = <String, String>{};
|
||||
for (final tagElement in nodeElement.findElements('tag')) {
|
||||
final key = tagElement.getAttribute('k');
|
||||
final value = tagElement.getAttribute('v');
|
||||
if (key != null && value != null) {
|
||||
tags[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node matches any of our profiles
|
||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||
surveillanceNodes[id] = {
|
||||
'id': id,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tags': tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: identify constrained nodes from ways
|
||||
for (final wayElement in document.findAllElements('way')) {
|
||||
for (final ndElement in wayElement.findElements('nd')) {
|
||||
final ref = int.tryParse(ndElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: identify constrained nodes from relations
|
||||
for (final relationElement in document.findAllElements('relation')) {
|
||||
for (final memberElement in relationElement.findElements('member')) {
|
||||
if (memberElement.getAttribute('type') == 'node') {
|
||||
final ref = int.tryParse(memberElement.getAttribute('ref') ?? '');
|
||||
if (ref != null && surveillanceNodes.containsKey(ref)) {
|
||||
constrainedNodeIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create OsmNode objects with constraint information
|
||||
final nodes = <OsmNode>[];
|
||||
for (final nodeData in surveillanceNodes.values) {
|
||||
final nodeId = nodeData['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
nodes.add(OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(nodeData['lat'], nodeData['lon']),
|
||||
tags: nodeData['tags'] as Map<String, String>,
|
||||
isConstrained: isConstrained,
|
||||
));
|
||||
|
||||
// Respect maxResults limit if set
|
||||
if (maxResults > 0 && nodes.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOsmApiNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Check if a node's tags match any of the given profiles
|
||||
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
|
||||
@@ -154,18 +154,13 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
@@ -190,6 +185,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Build node clauses for each profile
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
@@ -200,17 +196,19 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
}).join('\n ');
|
||||
|
||||
// Use unlimited output if maxResults is 0
|
||||
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
$outputClause
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
@@ -243,6 +241,56 @@ List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
@@ -58,6 +59,7 @@ class NodeCache {
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained, // Preserve constraint information
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,13 @@ class UploadQueueState extends ChangeNotifier {
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
// For constrained nodes, always use original position regardless of session.target
|
||||
final coordToUse = session.originalNode.isConstrained
|
||||
? session.originalNode.coord
|
||||
: session.target;
|
||||
|
||||
final upload = PendingUpload(
|
||||
coord: session.target,
|
||||
coord: coordToUse,
|
||||
direction: _formatDirectionsAsString(session.directions),
|
||||
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
|
||||
operatorProfile: session.operatorProfile,
|
||||
|
||||
@@ -11,8 +11,7 @@ class ChangelogDialog extends StatelessWidget {
|
||||
});
|
||||
|
||||
void _onClose(BuildContext context) async {
|
||||
// Update version tracking when closing changelog dialog
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -210,6 +210,24 @@ class EditNodeSheet extends StatelessWidget {
|
||||
// Direction controls
|
||||
_buildDirectionControls(context, appState, session, locService),
|
||||
|
||||
// Constraint message for nodes that cannot be moved
|
||||
if (session.originalNode.isConstrained)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('editNode.cannotMoveConstrainedNode'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!kEnableNodeEdits)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../models/osm_node.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'camera_icon.dart';
|
||||
@@ -62,6 +63,7 @@ class MapViewState extends State<MapView> {
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
|
||||
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
|
||||
|
||||
late final MapPositionManager _positionManager;
|
||||
late final TileLayerManager _tileManager;
|
||||
@@ -260,6 +262,29 @@ class MapViewState extends State<MapView> {
|
||||
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
|
||||
}
|
||||
|
||||
/// Get interaction options for the map based on whether we're editing a constrained node.
|
||||
/// Allows zoom and rotation but disables all forms of panning for constrained nodes.
|
||||
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
|
||||
// Check if we're editing a constrained node
|
||||
if (editSession?.originalNode.isConstrained == true) {
|
||||
// Constrained node: only allow pinch zoom and rotation, disable ALL panning
|
||||
return const InteractionOptions(
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal case: all interactions allowed
|
||||
return const InteractionOptions(
|
||||
flags: InteractiveFlag.all,
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
@@ -543,11 +568,7 @@ class MapViewState extends State<MapView> {
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: const InteractionOptions(
|
||||
scrollWheelVelocity: kScrollWheelVelocity,
|
||||
pinchZoomThreshold: kPinchZoomThreshold,
|
||||
pinchMoveThreshold: kPinchMoveThreshold,
|
||||
),
|
||||
interactionOptions: _getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
@@ -558,7 +579,35 @@ class MapViewState extends State<MapView> {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
if (editSession != null) {
|
||||
appState.updateEditSession(target: pos.center);
|
||||
// For constrained nodes, always snap back to original position
|
||||
if (editSession.originalNode.isConstrained) {
|
||||
final originalPos = editSession.originalNode.coord;
|
||||
|
||||
// Always keep session target as original position
|
||||
appState.updateEditSession(target: originalPos);
|
||||
|
||||
// Only snap back if position actually drifted, and debounce to wait for gesture completion
|
||||
if (pos.center.latitude != originalPos.latitude || pos.center.longitude != originalPos.longitude) {
|
||||
_constrainedNodeSnapBack(() {
|
||||
// Only animate if we're still in a constrained edit session and still drifted
|
||||
final currentEditSession = appState.editSession;
|
||||
if (currentEditSession?.originalNode.isConstrained == true) {
|
||||
final currentPos = _controller.mapController.camera.center;
|
||||
if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) {
|
||||
_controller.animateTo(
|
||||
dest: originalPos,
|
||||
zoom: _controller.mapController.camera.zoom,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normal unconstrained node - allow position updates
|
||||
appState.updateEditSession(target: pos.center);
|
||||
}
|
||||
}
|
||||
|
||||
// Update provisional pin location during navigation search/routing
|
||||
|
||||
@@ -1,109 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = locService.t('networkStatus.loading');
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = locService.t('networkStatus.timedOut');
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = locService.t('networkStatus.noData');
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Done';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = 'Showing limit - increase in settings';
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'Tile provider slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
case NetworkStatusType.success:
|
||||
message = locService.t('networkStatus.success');
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.nodeLimitReached:
|
||||
message = locService.t('networkStatus.nodeLimitReached');
|
||||
icon = Icons.visibility_off;
|
||||
color = Colors.amber;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = locService.t('networkStatus.tileProviderSlow');
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = locService.t('networkStatus.nodeDataSlow');
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = locService.t('networkStatus.networkIssues');
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
return Positioned(
|
||||
top: 8, // Position relative to the map area (not the screen)
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ class _WelcomeDialogState extends State<WelcomeDialog> {
|
||||
await ChangelogService().markWelcomeSeen();
|
||||
}
|
||||
|
||||
// Always update version tracking when closing welcome dialog
|
||||
await ChangelogService().updateLastSeenVersion();
|
||||
// Note: Version tracking is updated by completeVersionChange() after all dialogs
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 1.3.2+10 # The thing after the + is the version code, incremented with each release
|
||||
version: 1.3.3+11 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
|
||||
Reference in New Issue
Block a user