diff --git a/DEVELOPER.md b/DEVELOPER.md index cafc913..7faed70 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -135,18 +135,24 @@ The welcome popup explains that the app: **Why this approach:** Reduces API load by 3-4x while ensuring data freshness. User sees instant responses from cache while background fetching keeps data current. Eliminates complex dual-path logic in favor of simple spatial/temporal triggers. -### 2. Node Operations (Create/Edit/Delete) +### 2. Node Operations (Create/Edit/Delete/Extract) **Upload Operations Enum:** ```dart -enum UploadOperation { create, modify, delete } +enum UploadOperation { create, modify, delete, extract } ``` **Why explicit enum vs boolean flags:** -- **Brutalist**: Three explicit states instead of nullable booleans +- **Brutalist**: Four explicit states instead of nullable booleans - **Extensible**: Easy to add new operations (like bulk operations) - **Clear intent**: `operation == UploadOperation.delete` is unambiguous +**Operations explained:** +- **create**: Add new node to OSM +- **modify**: Update existing node's tags/position/direction +- **delete**: Remove existing node from OSM +- **extract**: Create new node with tags copied from constrained node, leaving original unchanged + **Session Pattern:** - `AddNodeSession`: For creating new nodes with single or multiple directions - `EditNodeSession`: For modifying existing nodes, preserving all existing directions diff --git a/README.md b/README.md index cf8281c..74e700e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,6 @@ cp lib/keys.dart.example lib/keys.dart - Dropdown on "refine tags" page to select acceptable options for camera:mount= - Tutorial / info guide before submitting first node - Link to "my changes" on osm (username edit history) -- Option to "extract node from way" for nodes attached to a way to allow moving ### On Pause - Suspected locations expansion to more regions diff --git a/assets/changelog.json b/assets/changelog.json index fd13b69..4eda41d 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,13 @@ { + "1.4.1": { + "content": [ + "• NEW: 'Extract node from way/relation' option for constrained nodes", + "• When editing nodes that are part of ways or relations, you can now check 'Extract node from way' to create a new node with the same tags at a new location", + "• This preserves the original node in its way/relation while creating an independent copy that can be moved freely", + "• Useful for cases where surveillance equipment has been relocated but the original node must remain for mapping accuracy", + "• Extraction creates a separate OSM changeset and node, leaving the original node untouched" + ] + }, "1.4.0": { "content": [ "• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)", diff --git a/lib/app_state.dart b/lib/app_state.dart index c1983be..5a77702 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -277,14 +277,21 @@ class AppState extends ChangeNotifier { NodeProfile? profile, OperatorProfile? operatorProfile, LatLng? target, + bool? extractFromWay, }) { _sessionState.updateEditSession( directionDeg: directionDeg, profile: profile, operatorProfile: operatorProfile, target: target, + extractFromWay: extractFromWay, ); } + + // For map view to check for pending snap backs + LatLng? consumePendingSnapBack() { + return _sessionState.consumePendingSnapBack(); + } void addDirection() { _sessionState.addDirection(); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 6d70460..3314666 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -57,7 +57,7 @@ const String kClientName = 'DeFlock'; const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv'; // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode // Navigation features - set to false to hide navigation UI elements while in development const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented diff --git a/lib/localizations/de.json b/lib/localizations/de.json index db245ed..c58bf23 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -100,6 +100,8 @@ "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 einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.", + "extractFromWay": "Knoten aus Weg/Relation extrahieren", + "extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen", "refineTags": "Tags Verfeinern", "refineTagsWithProfile": "Tags Verfeinern ({})" }, diff --git a/lib/localizations/en.json b/lib/localizations/en.json index d2443aa..404fcb7 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -118,6 +118,8 @@ "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 another map element (OSM way/relation). You can still edit its tags and direction.", + "extractFromWay": "Extract node from way/relation", + "extractFromWaySubtitle": "Create new node with same tags, allow moving to new location", "refineTags": "Refine Tags", "refineTagsWithProfile": "Refine Tags ({})" }, diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 2ba5a25..4592c7d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -118,6 +118,8 @@ "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 otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.", + "extractFromWay": "Extraer nodo de way/relation", + "extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación", "refineTags": "Refinar Etiquetas", "refineTagsWithProfile": "Refinar Etiquetas ({})" }, diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index c149391..1025e6c 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -118,6 +118,8 @@ "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 à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.", + "extractFromWay": "Extraire le nœud du way/relation", + "extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement", "refineTags": "Affiner Balises", "refineTagsWithProfile": "Affiner Balises ({})" }, diff --git a/lib/localizations/it.json b/lib/localizations/it.json index e070043..4329154 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -118,6 +118,8 @@ "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 un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.", + "extractFromWay": "Estrai nodo da way/relation", + "extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione", "refineTags": "Affina Tag", "refineTagsWithProfile": "Affina Tag ({})" }, diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 548ec9d..6fb3628 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -118,6 +118,8 @@ "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 outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.", + "extractFromWay": "Extrair nó do way/relation", + "extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização", "refineTags": "Refinar Tags", "refineTagsWithProfile": "Refinar Tags ({})" }, diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index c28b3e0..6985975 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -118,6 +118,8 @@ "enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。", "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。", "cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。", + "extractFromWay": "从way/relation中提取节点", + "extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置", "refineTags": "细化标签", "refineTagsWithProfile": "细化标签({})" }, diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index 389e541..a50dab2 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -3,7 +3,7 @@ import 'node_profile.dart'; import 'operator_profile.dart'; import '../state/settings_state.dart'; -enum UploadOperation { create, modify, delete } +enum UploadOperation { create, modify, delete, extract } class PendingUpload { final LatLng coord; @@ -32,12 +32,12 @@ class PendingUpload { this.completing = false, }) : assert( (operation == UploadOperation.create && originalNodeId == null) || - (operation != UploadOperation.create && originalNodeId != null), - 'originalNodeId must be null for create operations and non-null for modify/delete operations' + (operation == UploadOperation.create) || (originalNodeId != null), + 'originalNodeId must be null for create operations and non-null for modify/delete/extract operations' ), assert( (operation == UploadOperation.delete) || (profile != null), - 'profile is required for create and modify operations' + 'profile is required for create, modify, and extract operations' ); // True if this is an edit of an existing node, false if it's a new node @@ -45,6 +45,9 @@ class PendingUpload { // True if this is a deletion of an existing node bool get isDeletion => operation == UploadOperation.delete; + + // True if this is an extract operation (new node with tags from constrained node) + bool get isExtraction => operation == UploadOperation.extract; // Get display name for the upload destination String get uploadModeDisplayName { diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 797a60a..9d6a92f 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -17,8 +17,8 @@ class Uploader { try { print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}'); - // Safety check: create and modify operations MUST have profiles - if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify) && p.profile == null) { + // Safety check: create, modify, and extract operations MUST have profiles + if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify || p.operation == UploadOperation.extract) && p.profile == null) { print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data'); return false; } @@ -35,6 +35,9 @@ class Uploader { case UploadOperation.delete: action = 'Delete'; break; + case UploadOperation.extract: + action = 'Extract'; + break; } // Generate appropriate comment based on operation type final profileName = p.profile?.name ?? 'surveillance'; @@ -141,6 +144,23 @@ class Uploader { nodeResp = await _delete('/api/0.6/node/${p.originalNodeId}', nodeXml); nodeId = p.originalNodeId.toString(); break; + + case UploadOperation.extract: + // Extract creates a new node with tags from the original node + // The new node is created at the session's target coordinates + final mergedTags = p.getCombinedTags(); + final tagsXml = mergedTags.entries.map((e) => + '').join('\n '); + final nodeXml = ''' + + + $tagsXml + + '''; + print('Uploader: Extracting node from ${p.originalNodeId} to create new node...'); + nodeResp = await _put('/api/0.6/node/create', nodeXml); + nodeId = nodeResp.body.trim(); + break; } print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}'); diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 27fdcda..cfbdfcb 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -34,12 +34,14 @@ class EditNodeSession { LatLng target; // Current position (can be dragged) List directions; // All directions [90, 180, 270] int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°) + bool extractFromWay; // True if user wants to extract this constrained node EditNodeSession({ required this.originalNode, this.profile, required double initialDirection, required this.target, + this.extractFromWay = false, }) : directions = [initialDirection], currentDirectionIndex = 0; @@ -138,10 +140,14 @@ class SessionState extends ChangeNotifier { NodeProfile? profile, OperatorProfile? operatorProfile, LatLng? target, + bool? extractFromWay, }) { if (_editSession == null) return; bool dirty = false; + bool snapBackRequired = false; + LatLng? snapBackTarget; + if (directionDeg != null && directionDeg != _editSession!.directionDegrees) { _editSession!.directionDegrees = directionDeg; dirty = true; @@ -158,7 +164,31 @@ class SessionState extends ChangeNotifier { _editSession!.target = target; dirty = true; } + if (extractFromWay != null && extractFromWay != _editSession!.extractFromWay) { + _editSession!.extractFromWay = extractFromWay; + // When extract is unchecked, snap back to original location + if (!extractFromWay) { + _editSession!.target = _editSession!.originalNode.coord; + snapBackRequired = true; + snapBackTarget = _editSession!.originalNode.coord; + } + dirty = true; + } + if (dirty) notifyListeners(); + + // Store snap back info for map view to pick up + if (snapBackRequired && snapBackTarget != null) { + _pendingSnapBack = snapBackTarget; + } + } + + // For map view to check and consume snap back requests + LatLng? _pendingSnapBack; + LatLng? consumePendingSnapBack() { + final result = _pendingSnapBack; + _pendingSnapBack = null; + return result; } // Add new direction at 0° and switch to editing it diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 8aafa63..bf88bea 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:latlong2/latlong.dart'; import '../models/pending_upload.dart'; import '../models/osm_node.dart'; @@ -61,10 +62,23 @@ 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; + // Determine operation type and coordinates + final UploadOperation operation; + final LatLng coordToUse; + + if (session.extractFromWay && session.originalNode.isConstrained) { + // Extract operation: create new node at new location + operation = UploadOperation.extract; + coordToUse = session.target; + } else if (session.originalNode.isConstrained) { + // Constrained node without extract: use original position + operation = UploadOperation.modify; + coordToUse = session.originalNode.coord; + } else { + // Unconstrained node: normal modify operation + operation = UploadOperation.modify; + coordToUse = session.target; + } final upload = PendingUpload( coord: coordToUse, @@ -72,38 +86,54 @@ class UploadQueueState extends ChangeNotifier { profile: session.profile!, // Safe to use ! because commitEditSession() checks for null operatorProfile: session.operatorProfile, uploadMode: uploadMode, - operation: UploadOperation.modify, + operation: operation, originalNodeId: session.originalNode.id, // Track which node we're editing ); _queue.add(upload); _saveQueue(); - // Create two cache entries: - - // 1. Mark the original node with _pending_edit (grey ring) at original location - final originalTags = Map.from(session.originalNode.tags); - originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit - - final originalNode = OsmNode( - id: session.originalNode.id, - coord: session.originalNode.coord, // Keep at original location - tags: originalTags, - ); - - // 2. Create new temp node for the edited node (purple ring) at new location - final tempId = -DateTime.now().millisecondsSinceEpoch; - final editedTags = upload.getCombinedTags(); - editedTags['_pending_upload'] = 'true'; // Mark as pending upload - editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing - - final editedNode = OsmNode( - id: tempId, - coord: upload.coord, // At new location - tags: editedTags, - ); - - NodeCache.instance.addOrUpdate([originalNode, editedNode]); + // Create cache entries based on operation type: + if (operation == UploadOperation.extract) { + // For extract: only create new node, leave original unchanged + final tempId = -DateTime.now().millisecondsSinceEpoch; + final extractedTags = upload.getCombinedTags(); + extractedTags['_pending_upload'] = 'true'; // Mark as pending upload + extractedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + + final extractedNode = OsmNode( + id: tempId, + coord: upload.coord, // At new location + tags: extractedTags, + ); + + NodeCache.instance.addOrUpdate([extractedNode]); + } else { + // For modify: mark original with grey ring and create new temp node + // 1. Mark the original node with _pending_edit (grey ring) at original location + final originalTags = Map.from(session.originalNode.tags); + originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit + + final originalNode = OsmNode( + id: session.originalNode.id, + coord: session.originalNode.coord, // Keep at original location + tags: originalTags, + ); + + // 2. Create new temp node for the edited node (purple ring) at new location + final tempId = -DateTime.now().millisecondsSinceEpoch; + final editedTags = upload.getCombinedTags(); + editedTags['_pending_upload'] = 'true'; // Mark as pending upload + editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing + + final editedNode = OsmNode( + id: tempId, + coord: upload.coord, // At new location + tags: editedTags, + ); + + NodeCache.instance.addOrUpdate([originalNode, editedNode]); + } // Notify node provider to update the map CameraProviderWithCache.instance.notifyListeners(); @@ -277,7 +307,8 @@ class UploadQueueState extends ChangeNotifier { // Clean up any temp nodes at the same coordinate NodeCache.instance.removeTempNodesByCoordinate(item.coord); - // For edits, also clean up the original node's _pending_edit marker + // For modify operations, clean up the original node's _pending_edit marker + // For extract operations, we don't modify the original node so leave it unchanged if (item.isEdit && item.originalNodeId != null) { // Remove the _pending_edit marker from the original node in cache // The next Overpass fetch will provide the authoritative data anyway diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 14dc735..58ba75c 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -88,10 +88,12 @@ class AddNodeSheet extends StatelessWidget { icon: Icon( Icons.add, size: 20, - color: requiresDirection ? null : Theme.of(context).disabledColor, + color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), - onPressed: requiresDirection ? () => appState.addDirection() : null, - tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile', + onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null, + tooltip: requiresDirection + ? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction') + : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 82ca478..9f11804 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -90,10 +90,12 @@ class EditNodeSheet extends StatelessWidget { icon: Icon( Icons.add, size: 20, - color: requiresDirection ? null : Theme.of(context).disabledColor, + color: requiresDirection && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), - onPressed: requiresDirection ? () => appState.addDirection() : null, - tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile', + onPressed: requiresDirection && session.directions.length < 8 ? () => appState.addDirection() : null, + tooltip: requiresDirection + ? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction') + : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), @@ -217,19 +219,34 @@ class EditNodeSheet extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( children: [ - 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, - ), - ), - ], + // Extract from way checkbox + CheckboxListTile( + title: Text(locService.t('editNode.extractFromWay')), + subtitle: Text(locService.t('editNode.extractFromWaySubtitle')), + value: session.extractFromWay, + onChanged: (value) { + appState.updateEditSession(extractFromWay: value); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), + // Constraint info message (only show if extract is not checked) + if (!session.extractFromWay) ...[ + 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, + ), + ), + ], + ), + const SizedBox(height: 8), + ], Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index f7557ab..b4f1919 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -263,11 +263,11 @@ class MapViewState extends State { } /// 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. + /// Allows zoom and rotation but disables all forms of panning for constrained nodes unless extract is enabled. 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 + // Check if we're editing a constrained node that's not being extracted + if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) { + // Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning return const InteractionOptions( enableMultiFingerGestureRace: true, flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate, @@ -379,6 +379,19 @@ class MapViewState extends State { } catch (_) {/* controller not ready yet */} } + // Check for pending snap backs (when extract checkbox is unchecked) + final snapBackTarget = appState.consumePendingSnapBack(); + if (snapBackTarget != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.animateTo( + dest: snapBackTarget, + zoom: _controller.mapController.camera.zoom, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 250), + ); + }); + } + // Edit sessions don't need to center - we're already centered from the node tap // SheetAwareMap handles the visual positioning @@ -575,6 +588,7 @@ class MapViewState extends State { options: MapOptions( initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), initialZoom: _positionManager.initialZoom ?? 15, + minZoom: 1.0, maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(), interactionOptions: _getInteractionOptions(editSession), onPositionChanged: (pos, gesture) { @@ -587,8 +601,8 @@ class MapViewState extends State { appState.updateSession(target: pos.center); } if (editSession != null) { - // For constrained nodes, always snap back to original position - if (editSession.originalNode.isConstrained) { + // For constrained nodes that are not being extracted, always snap back to original position + if (editSession.originalNode.isConstrained && !editSession.extractFromWay) { final originalPos = editSession.originalNode.coord; // Always keep session target as original position @@ -599,7 +613,7 @@ class MapViewState extends State { _constrainedNodeSnapBack(() { // Only animate if we're still in a constrained edit session and still drifted final currentEditSession = appState.editSession; - if (currentEditSession?.originalNode.isConstrained == true) { + if (currentEditSession?.originalNode.isConstrained == true && currentEditSession?.extractFromWay != true) { final currentPos = _controller.mapController.camera.center; if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) { _controller.animateTo( diff --git a/pubspec.yaml b/pubspec.yaml index d7e4b5c..9892f07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.4.0+13 # The thing after the + is the version code, incremented with each release +version: 1.4.1+14 # 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+