diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 4456f7d..b02b94c 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -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}) { diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 41cb53a..9356133 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -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", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 384a304..871ff28 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -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", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index cc978c7..5edc157 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -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", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 5acae0a..3d5648a 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -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", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6452984..a987570 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -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", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index e722bc9..bb96db6 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -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", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 3e3389d..3a228fc 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -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": "搜索位置", diff --git a/lib/models/osm_node.dart b/lib/models/osm_node.dart index 613bd4f..a5f0961 100644 --- a/lib/models/osm_node.dart +++ b/lib/models/osm_node.dart @@ -4,11 +4,13 @@ class OsmNode { final int id; final LatLng coord; final Map 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 toJson() => { @@ -16,6 +18,7 @@ class OsmNode { 'lat': coord.latitude, 'lon': coord.longitude, 'tags': tags, + 'isConstrained': isConstrained, }; factory OsmNode.fromJson(Map 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 ); } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 71a8aee..afc4b22 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -47,44 +47,7 @@ Future> fetchOsmApiNodes({ // Parse XML response final document = XmlDocument.parse(response.body); - final nodes = []; - - // 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 = {}; - 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> fetchOsmApiNodes({ } } +/// Parse OSM API XML response to create OsmNode objects with constraint information. +List _parseOsmApiResponseWithConstraints(XmlDocument document, List profiles, int maxResults) { + final surveillanceNodes = >{}; // nodeId -> node data + final constrainedNodeIds = {}; + + // 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 = {}; + 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 = []; + 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, + 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 nodeTags, List profiles) { for (final profile in profiles) { diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index cd24322..f7b75c4 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -154,18 +154,13 @@ Future> _fetchSingleOverpassQuery({ final elements = data['elements'] as List; 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((element) { - return OsmNode( - id: element['id'], - coord: LatLng(element['lat'], element['lon']), - tags: Map.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> _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 profiles, int maxResults) { // Build node clauses for each profile final nodeClauses = profiles.map((profile) { @@ -200,17 +196,19 @@ String _buildOverpassQuery(LatLngBounds bounds, List 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 _splitBounds(LatLngBounds bounds) { ]; } +/// Parse Overpass response elements to create OsmNode objects with constraint information. +List _parseOverpassResponseWithConstraints(List elements) { + final nodeElements = >[]; + final constrainedNodeIds = {}; + + // First pass: collect surveillance nodes and identify constrained nodes + for (final element in elements.whereType>()) { + 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? ?? + 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.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 overpassNodes) { try { diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 92108de..8e1f45f 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -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 ); } } diff --git a/lib/widgets/changelog_dialog.dart b/lib/widgets/changelog_dialog.dart index 2f29b3e..862508b 100644 --- a/lib/widgets/changelog_dialog.dart +++ b/lib/widgets/changelog_dialog.dart @@ -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(); diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 55d4e79..3bef64c 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -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), diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 4441936..bdf3252 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -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'; @@ -260,6 +261,28 @@ class MapViewState extends State { 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 panning/dragging for constrained nodes. + InteractionOptions _getInteractionOptions(EditNodeSession? editSession) { + // Check if we're editing a constrained node + if (editSession?.originalNode.isConstrained == true) { + // Constrained node: disable dragging/panning but keep zoom, rotate, etc. + return const InteractionOptions( + flags: InteractiveFlag.all & ~InteractiveFlag.drag, + scrollWheelVelocity: kScrollWheelVelocity, + pinchZoomThreshold: kPinchZoomThreshold, + pinchMoveThreshold: kPinchMoveThreshold, + ); + } + + // Normal case: all interactions allowed + return const InteractionOptions( + 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 +566,7 @@ class MapViewState extends State { 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) { diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index eabdf4d..581f0c6 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -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( - 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( + 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, + ), + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/lib/widgets/welcome_dialog.dart b/lib/widgets/welcome_dialog.dart index fa11527..096285e 100644 --- a/lib/widgets/welcome_dialog.dart +++ b/lib/widgets/welcome_dialog.dart @@ -25,8 +25,7 @@ class _WelcomeDialogState extends State { 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();