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+