diff --git a/README.md b/README.md index 847cfdb..119dcc2 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ cp lib/keys.dart.example lib/keys.dart ## Roadmap ### Needed Bugfixes -- Default profile selection "" when editing an existing node +- Old nodes are sticking around after edit submissions go through, at least in simulate mode. I think we prune those from cache when in production mode at least? +- Ask for location permission on first launch, temp disable notification permission - Make submission guide scarier - Tile cache trimming? Does fluttermap handle? - Filter NSI suggestions based on what has already been typed in @@ -112,7 +113,6 @@ cp lib/keys.dart.example lib/keys.dart - Clean cache when nodes have been deleted by others ### Current Development -- Populate a temp operator profile and select it by default when those tags already exist on a node being edited - Add ability to downvote suspected locations which are old enough - Turn by turn navigation or at least swipe nav sheet up to see a list - Import/Export map providers diff --git a/assets/changelog.json b/assets/changelog.json index 1c6dee8..22bd7c3 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,14 @@ { + "2.6.2": { + "content": [ + "• Enhanced edit workflow - existing device properties are preserved by default, reducing accidental tag loss during edits", + "• New '' profile when editing nodes; preserves current device tags while allowing direction and location edits", + "• New '' profile when editing nodes with operator tags; preserves operator details automatically", + "• Tag pre-population; when switching profiles, existing node values automatically fill empty profile tags to prevent data loss", + "• Smart operator matching - existing operator tags automatically match saved operator profiles when possible", + "• Operator profile selection now persists across main profile selection changes" + ] + }, "2.6.1": { "content": [ "• Simplified network status indicator - cleaner state management", diff --git a/lib/app_state.dart b/lib/app_state.dart index 2231b46..0e200b5 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -435,7 +435,7 @@ class AppState extends ChangeNotifier { } void startEditSession(OsmNode node) { - _sessionState.startEditSession(node, enabledProfiles); + _sessionState.startEditSession(node, enabledProfiles, operatorProfiles); } void updateSession({ @@ -529,6 +529,8 @@ class AppState extends ChangeNotifier { _sessionState.removeDirection(); } + bool get canRemoveDirection => _sessionState.canRemoveDirection; + void cycleDirection() { _sessionState.cycleDirection(); } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index ad0ea52..6ffe5e6 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -72,7 +72,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-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 = true; // Hide navigation until fully implemented diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 2fa2fb6..6d9d3dd 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -104,8 +104,7 @@ "enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.", "profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.", "loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.", - "refineTags": "Tags Verfeinern", - "refineTagsWithProfile": "Tags Verfeinern ({})" + "refineTags": "Tags Verfeinern" }, "editNode": { "title": "Knoten #{} Bearbeiten", @@ -125,7 +124,7 @@ "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 ({})" + "existingTags": "" }, "download": { "title": "Kartenbereich Herunterladen", @@ -383,7 +382,11 @@ "profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:", "selectValue": "Wert auswählen...", "noValue": "(Kein Wert)", - "noSuggestions": "Keine Vorschläge verfügbar" + "noSuggestions": "Keine Vorschläge verfügbar", + "existingTagsTitle": "Vorhandene Tags", + "existingTagsDescription": "Bearbeiten Sie die vorhandenen Tags auf diesem Gerät. Hinzufügen, entfernen oder ändern Sie beliebige Tags:", + "existingOperator": "", + "existingOperatorTags": "vorhandene Betreiber-Tags" }, "layerSelector": { "cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 8856abb..0aca05f 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.", "profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.", "loadingAreaData": "Loading area data... Please wait before submitting.", - "refineTags": "Refine Tags", - "refineTagsWithProfile": "Refine Tags ({})" + "refineTags": "Refine Tags" }, "editNode": { "title": "Edit Node #{}", @@ -162,7 +161,7 @@ "extractFromWay": "Extract node from way/relation", "extractFromWaySubtitle": "Create new node with same tags, allow moving to new location", "refineTags": "Refine Tags", - "refineTagsWithProfile": "Refine Tags ({})" + "existingTags": "" }, "download": { "title": "Download Map Area", @@ -420,7 +419,11 @@ "profileTagsDescription": "Complete these optional tag values for more detailed submissions:", "selectValue": "Select value...", "noValue": "(leave empty)", - "noSuggestions": "No suggestions available" + "noSuggestions": "No suggestions available", + "existingTagsTitle": "Existing Tags", + "existingTagsDescription": "Edit the existing tags on this device. Add, remove, or modify any tag:", + "existingOperator": "", + "existingOperatorTags": "existing operator tags" }, "layerSelector": { "cannotChangeTileTypes": "Cannot change tile types while downloading offline areas", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 69acca3..3dc4f16 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.", "profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.", "loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.", - "refineTags": "Refinar Etiquetas", - "refineTagsWithProfile": "Refinar Etiquetas ({})" + "refineTags": "Refinar Etiquetas" }, "editNode": { "title": "Editar Nodo #{}", @@ -162,7 +161,7 @@ "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 ({})" + "existingTags": "" }, "download": { "title": "Descargar Área del Mapa", @@ -420,7 +419,11 @@ "profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:", "selectValue": "Seleccionar un valor...", "noValue": "(Sin valor)", - "noSuggestions": "No hay sugerencias disponibles" + "noSuggestions": "No hay sugerencias disponibles", + "existingTagsTitle": "Etiquetas Existentes", + "existingTagsDescription": "Edite las etiquetas existentes en este dispositivo. Agregue, elimine o modifique cualquier etiqueta:", + "existingOperator": "", + "existingOperatorTags": "etiquetas de operador existentes" }, "layerSelector": { "cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index bdb8472..1fe71cf 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.", "profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.", "loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.", - "refineTags": "Affiner Balises", - "refineTagsWithProfile": "Affiner Balises ({})" + "refineTags": "Affiner Balises" }, "editNode": { "title": "Modifier Nœud #{}", @@ -162,7 +161,7 @@ "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 ({})" + "existingTags": "" }, "download": { "title": "Télécharger Zone de Carte", @@ -420,7 +419,11 @@ "profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :", "selectValue": "Sélectionner une valeur...", "noValue": "(Aucune valeur)", - "noSuggestions": "Aucune suggestion disponible" + "noSuggestions": "Aucune suggestion disponible", + "existingTagsTitle": "Balises Existantes", + "existingTagsDescription": "Modifiez les balises existantes sur cet appareil. Ajoutez, supprimez ou modifiez n'importe quelle balise :", + "existingOperator": "", + "existingOperatorTags": "balises d'opérateur existantes" }, "layerSelector": { "cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index ebd93c4..45857f2 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.", "profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.", "loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.", - "refineTags": "Affina Tag", - "refineTagsWithProfile": "Affina Tag ({})" + "refineTags": "Affina Tag" }, "editNode": { "title": "Modifica Nodo #{}", @@ -162,7 +161,7 @@ "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 ({})" + "existingTags": "" }, "download": { "title": "Scarica Area Mappa", @@ -420,7 +419,11 @@ "profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:", "selectValue": "Seleziona un valore...", "noValue": "(Nessun valore)", - "noSuggestions": "Nessun suggerimento disponibile" + "noSuggestions": "Nessun suggerimento disponibile", + "existingTagsTitle": "Tag Esistenti", + "existingTagsDescription": "Modifica i tag esistenti su questo dispositivo. Aggiungi, rimuovi o modifica qualsiasi tag:", + "existingOperator": "", + "existingOperatorTags": "tag operatore esistenti" }, "layerSelector": { "cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 5b943de..f2150cc 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.", "profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.", "loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.", - "refineTags": "Refinar Tags", - "refineTagsWithProfile": "Refinar Tags ({})" + "refineTags": "Refinar Tags" }, "editNode": { "title": "Editar Nó #{}", @@ -162,7 +161,7 @@ "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 ({})" + "existingTags": "" }, "download": { "title": "Baixar Área do Mapa", @@ -420,7 +419,11 @@ "profileTagsDescription": "Especifique valores para tags que precisam de refinamento:", "selectValue": "Selecionar um valor...", "noValue": "(Sem valor)", - "noSuggestions": "Nenhuma sugestão disponível" + "noSuggestions": "Nenhuma sugestão disponível", + "existingTagsTitle": "Tags Existentes", + "existingTagsDescription": "Edite as tags existentes neste dispositivo. Adicione, remova ou modifique qualquer tag:", + "existingOperator": "", + "existingOperatorTags": "tags de operador existentes" }, "layerSelector": { "cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 4156b69..1f3d0b3 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -141,8 +141,7 @@ "enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。", "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。", "loadingAreaData": "正在加载区域数据...提交前请稍候。", - "refineTags": "细化标签", - "refineTagsWithProfile": "细化标签({})" + "refineTags": "细化标签" }, "editNode": { "title": "编辑节点 #{}", @@ -162,7 +161,7 @@ "extractFromWay": "从way/relation中提取节点", "extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置", "refineTags": "细化标签", - "refineTagsWithProfile": "细化标签({})" + "existingTags": "<现有标签>" }, "download": { "title": "下载地图区域", @@ -420,7 +419,11 @@ "profileTagsDescription": "为需要细化的标签指定值:", "selectValue": "选择值...", "noValue": "(无值)", - "noSuggestions": "无建议可用" + "noSuggestions": "无建议可用", + "existingTagsTitle": "现有标签", + "existingTagsDescription": "编辑此设备上的现有标签。添加、删除或修改任何标签:", + "existingOperator": "<现有运营商>", + "existingOperatorTags": "现有运营商标签" }, "layerSelector": { "cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型", diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index 4a41bd0..46a201d 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -1,4 +1,5 @@ import 'package:uuid/uuid.dart'; +import 'osm_node.dart'; /// Sentinel value for copyWith methods to distinguish between null and not provided const Object _notProvided = Object(); @@ -264,5 +265,31 @@ class NodeProfile { @override int get hashCode => id.hashCode; + + /// Create a temporary profile representing the existing tags on a node (minus direction and operator) + /// Used as the default "" option when editing nodes + static NodeProfile createExistingTagsProfile(OsmNode node) { + final tagsWithoutSpecial = Map.from(node.tags); + // Remove direction tags (handled separately) + tagsWithoutSpecial.remove('direction'); + tagsWithoutSpecial.remove('camera:direction'); + + // Remove operator tags (handled separately by operator profile) + tagsWithoutSpecial.removeWhere((key, value) => + key == 'operator' || key.startsWith('operator:')); + + return NodeProfile( + id: 'temp-no-change-${node.id}', + name: '', // Will be localized in UI + tags: tagsWithoutSpecial, + builtin: false, + requiresDirection: true, + submittable: true, + editable: false, + ); + } + + /// Returns true if this is a temporary "existing tags" profile + bool get isExistingTagsProfile => id.startsWith('temp-no-change-'); } diff --git a/lib/models/operator_profile.dart b/lib/models/operator_profile.dart index 96763d0..30db363 100644 --- a/lib/models/operator_profile.dart +++ b/lib/models/operator_profile.dart @@ -1,4 +1,5 @@ import 'package:uuid/uuid.dart'; +import 'osm_node.dart'; /// A bundle of OSM tags that describe a particular surveillance operator. /// These are applied on top of camera profile tags during submissions. @@ -76,4 +77,56 @@ class OperatorProfile { @override int get hashCode => id.hashCode; + + /// Create a temporary operator profile from existing operator tags on a node + /// First tries to match against saved operator profiles, otherwise creates temporary one + /// Used as the default operator profile when editing nodes + static OperatorProfile? createExistingOperatorProfile(OsmNode node, List savedProfiles) { + final operatorTags = _extractOperatorTags(node.tags); + if (operatorTags.isEmpty) return null; + + // First, try to find a perfect match among saved profiles + for (final savedProfile in savedProfiles) { + if (_tagsMatch(savedProfile.tags, operatorTags)) { + return savedProfile; + } + } + + // No perfect match found, create temporary profile + final operatorName = operatorTags['operator'] ?? ''; + + return OperatorProfile( + id: 'temp-existing-operator-${node.id}', + name: operatorName, + tags: operatorTags, + ); + } + + /// Check if two tag maps are identical + static bool _tagsMatch(Map tags1, Map tags2) { + if (tags1.length != tags2.length) return false; + + for (final entry in tags1.entries) { + if (tags2[entry.key] != entry.value) return false; + } + + return true; + } + + /// Extract all operator-related tags from a node's tags + static Map _extractOperatorTags(Map tags) { + final operatorTags = {}; + + for (final entry in tags.entries) { + // Include operator= and any operator:*= tags + if (entry.key == 'operator' || entry.key.startsWith('operator:')) { + operatorTags[entry.key] = entry.value; + } + } + + return operatorTags; + } + + /// Returns true if this is a temporary "existing operator" profile + bool get isExistingOperatorProfile => id.startsWith('temp-existing-operator-'); } \ No newline at end of file diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 9058012..3481c07 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -25,13 +25,20 @@ class AddNodeSession { refinedTags = refinedTags ?? {}; // Slider always shows the current direction being edited - double get directionDegrees => directions[currentDirectionIndex]; - set directionDegrees(double value) => directions[currentDirectionIndex] = value; + double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 + ? directions[currentDirectionIndex] + : 0.0; + set directionDegrees(double value) { + if (directions.isNotEmpty && currentDirectionIndex >= 0) { + directions[currentDirectionIndex] = value; + } + } } // ------------------ EditNodeSession ------------------ class EditNodeSession { final OsmNode originalNode; // The original node being edited + final bool originalHadDirections; // Whether original node had any directions NodeProfile? profile; OperatorProfile? operatorProfile; LatLng target; // Current position (can be dragged) @@ -42,7 +49,9 @@ class EditNodeSession { EditNodeSession({ required this.originalNode, + required this.originalHadDirections, this.profile, + this.operatorProfile, required double initialDirection, required this.target, this.extractFromWay = false, @@ -52,13 +61,20 @@ class EditNodeSession { refinedTags = refinedTags ?? {}; // Slider always shows the current direction being edited - double get directionDegrees => directions[currentDirectionIndex]; - set directionDegrees(double value) => directions[currentDirectionIndex] = value; + double get directionDegrees => directions.isNotEmpty && currentDirectionIndex >= 0 + ? directions[currentDirectionIndex] + : 0.0; + set directionDegrees(double value) { + if (directions.isNotEmpty && currentDirectionIndex >= 0) { + directions[currentDirectionIndex] = value; + } + } } class SessionState extends ChangeNotifier { AddNodeSession? _session; EditNodeSession? _editSession; + OperatorProfile? _detectedOperatorProfile; // Persists across profile changes // Getters AddNodeSession? get session => _session; @@ -71,34 +87,30 @@ class SessionState extends ChangeNotifier { notifyListeners(); } - void startEditSession(OsmNode node, List enabledProfiles) { - final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList(); + void startEditSession(OsmNode node, List enabledProfiles, List operatorProfiles) { + // Always create and pre-select the temporary "existing tags" profile + final existingTagsProfile = NodeProfile.createExistingTagsProfile(node); - // Try to find a matching profile based on the node's tags - NodeProfile? matchingProfile; + // Detect and store operator profile (persists across profile changes) + _detectedOperatorProfile = OperatorProfile.createExistingOperatorProfile(node, operatorProfiles); - // Attempt to find a match by comparing tags - for (final profile in submittableProfiles) { - if (_profileMatchesTags(profile, node.tags)) { - matchingProfile = profile; - break; - } - } - - // Start with no profile selected if no match found - force user to choose - // Initialize edit session with all existing directions - final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0]; + // Initialize edit session with all existing directions, or empty list if none + final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : []; + final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0; + final originalHadDirections = existingDirections.isNotEmpty; _editSession = EditNodeSession( originalNode: node, - profile: matchingProfile, - initialDirection: existingDirections.first, + originalHadDirections: originalHadDirections, + profile: existingTagsProfile, + operatorProfile: _detectedOperatorProfile, + initialDirection: initialDirection, target: node.coord, ); - // Replace the default single direction with all existing directions + // Replace the default single direction with all existing directions (or empty list) _editSession!.directions = List.from(existingDirections); - _editSession!.currentDirectionIndex = 0; // Start editing the first direction + _editSession!.currentDirectionIndex = existingDirections.isNotEmpty ? 0 : -1; // -1 indicates no directions _session = null; // Clear any add session notifyListeners(); } @@ -165,10 +177,22 @@ class SessionState extends ChangeNotifier { dirty = true; } if (profile != null && profile != _editSession!.profile) { + final oldProfile = _editSession!.profile; _editSession!.profile = profile; + + // Handle direction requirements when profile changes + _handleDirectionRequirementsOnProfileChange(oldProfile, profile); + + // When profile changes but operator profile not explicitly provided, + // restore the detected operator profile (if any) + if (operatorProfile == null && _detectedOperatorProfile != null) { + _editSession!.operatorProfile = _detectedOperatorProfile; + } + dirty = true; } - if (operatorProfile != _editSession!.operatorProfile) { + // Only update operator profile if explicitly provided or different from current + if (operatorProfile != null && operatorProfile != _editSession!.operatorProfile) { _editSession!.operatorProfile = operatorProfile; dirty = true; } @@ -222,18 +246,28 @@ class SessionState extends ChangeNotifier { // Remove currently selected direction void removeDirection() { - if (_session != null && _session!.directions.length > 1) { - _session!.directions.removeAt(_session!.currentDirectionIndex); - if (_session!.currentDirectionIndex >= _session!.directions.length) { - _session!.currentDirectionIndex = _session!.directions.length - 1; + if (_session != null && _session!.directions.isNotEmpty) { + // For add sessions, keep minimum of 1 direction + if (_session!.directions.length > 1) { + _session!.directions.removeAt(_session!.currentDirectionIndex); + if (_session!.currentDirectionIndex >= _session!.directions.length) { + _session!.currentDirectionIndex = _session!.directions.length - 1; + } + notifyListeners(); } - notifyListeners(); - } else if (_editSession != null && _editSession!.directions.length > 1) { - _editSession!.directions.removeAt(_editSession!.currentDirectionIndex); - if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) { - _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; + } else if (_editSession != null && _editSession!.directions.isNotEmpty) { + // For edit sessions, use minimum calculation + final minDirections = _getMinimumDirections(); + + if (_editSession!.directions.length > minDirections) { + _editSession!.directions.removeAt(_editSession!.currentDirectionIndex); + if (_editSession!.directions.isEmpty) { + _editSession!.currentDirectionIndex = -1; // No directions + } else if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) { + _editSession!.currentDirectionIndex = _editSession!.directions.length - 1; + } + notifyListeners(); } - notifyListeners(); } } @@ -242,7 +276,7 @@ class SessionState extends ChangeNotifier { if (_session != null && _session!.directions.length > 1) { _session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length; notifyListeners(); - } else if (_editSession != null && _editSession!.directions.length > 1) { + } else if (_editSession != null && _editSession!.directions.length > 1 && _editSession!.currentDirectionIndex >= 0) { _editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length; notifyListeners(); } @@ -257,6 +291,7 @@ class SessionState extends ChangeNotifier { void cancelEditSession() { _editSession = null; + _detectedOperatorProfile = null; notifyListeners(); } @@ -274,7 +309,36 @@ class SessionState extends ChangeNotifier { final session = _editSession!; _editSession = null; + _detectedOperatorProfile = null; notifyListeners(); return session; } + + /// Get the minimum number of directions required for current session state + int _getMinimumDirections() { + if (_editSession == null) return 1; + + // Minimum = 0 only if original had no directions AND currently using existing tags profile + final isExistingTags = _editSession!.profile?.isExistingTagsProfile == true; + return (_editSession!.originalHadDirections || !isExistingTags) ? 1 : 0; + } + + /// Check if remove direction button should be enabled for edit session + bool get canRemoveDirection { + if (_editSession == null || _editSession!.directions.isEmpty) return false; + return _editSession!.directions.length > _getMinimumDirections(); + } + + /// Handle direction requirements when profile changes in edit session + void _handleDirectionRequirementsOnProfileChange(NodeProfile? oldProfile, NodeProfile newProfile) { + if (_editSession == null) return; + + final minimum = _getMinimumDirections(); + + // Ensure we meet the minimum (add direction if needed) + if (_editSession!.directions.length < minimum) { + _editSession!.directions = [0.0]; + _editSession!.currentDirectionIndex = 0; + } + } } \ No newline at end of file diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 5699dde..1e0443b 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -578,9 +578,7 @@ class _AddNodeSheetState extends State { child: OutlinedButton.icon( onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected icon: const Icon(Icons.tune), - label: Text(session.operatorProfile != null - ? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name]) - : locService.t('addNode.refineTags')), + label: Text(locService.t('addNode.refineTags')), ), ), ), diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 52441f5..4630c95 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -153,7 +153,9 @@ class _EditNodeSheetState extends State { Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) { final requiresDirection = session.profile != null && session.profile!.requiresDirection; final is360Fov = session.profile?.fov == 360; - final enableDirectionControls = requiresDirection && !is360Fov; + final hasDirections = session.directions.isNotEmpty; + final enableDirectionControls = requiresDirection && !is360Fov && hasDirections; + final enableAddButton = requiresDirection && !is360Fov; // Force direction to 0 when FOV is 360 (omnidirectional) if (is360Fov && session.directionDegrees != 0) { @@ -164,7 +166,7 @@ class _EditNodeSheetState extends State { // Format direction display text with bold for current direction String directionsText = ''; - if (requiresDirection) { + if (requiresDirection && hasDirections) { final directionsWithBold = []; for (int i = 0; i < session.directions.length; i++) { final dirStr = session.directions[i].round().toString(); @@ -195,7 +197,12 @@ class _EditNodeSheetState extends State { fontWeight: isEven ? FontWeight.normal : FontWeight.bold, ), ); - }), + }) + else + const TextSpan( + text: 'None', + style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), + ), ], ), ) @@ -220,12 +227,16 @@ class _EditNodeSheetState extends State { icon: Icon( Icons.remove, size: 20, - color: enableDirectionControls ? null : Theme.of(context).disabledColor, + color: enableDirectionControls && appState.canRemoveDirection ? null : Theme.of(context).disabledColor, ), - onPressed: enableDirectionControls && session.directions.length > 1 + onPressed: enableDirectionControls && appState.canRemoveDirection ? () => appState.removeDirection() : null, - tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile', + tooltip: requiresDirection + ? (hasDirections + ? (appState.canRemoveDirection ? 'Remove current direction' : 'Cannot remove - minimum reached') + : 'No directions to remove') + : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), @@ -234,9 +245,9 @@ class _EditNodeSheetState extends State { icon: Icon( Icons.add, size: 20, - color: enableDirectionControls && session.directions.length < 8 ? null : Theme.of(context).disabledColor, + color: enableAddButton && session.directions.length < 8 ? null : Theme.of(context).disabledColor, ), - onPressed: enableDirectionControls && session.directions.length < 8 ? () => appState.addDirection() : null, + onPressed: enableAddButton && 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', @@ -248,19 +259,23 @@ class _EditNodeSheetState extends State { icon: Icon( Icons.repeat, size: 20, - color: enableDirectionControls ? null : Theme.of(context).disabledColor, + color: enableDirectionControls && session.directions.length > 1 ? null : Theme.of(context).disabledColor, ), onPressed: enableDirectionControls && session.directions.length > 1 ? () => appState.cycleDirection() : null, - tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile', + tooltip: requiresDirection + ? (hasDirections + ? (session.directions.length > 1 ? 'Cycle through directions' : 'Only one direction') + : 'No directions to cycle') + : 'Direction not required for this profile', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight), ), ], ), ), - // Show info text when profile doesn't require direction + // Show info text when profile doesn't require direction or when no directions exist if (!requiresDirection) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), @@ -276,6 +291,22 @@ class _EditNodeSheetState extends State { ), ], ), + ) + else if (requiresDirection && !hasDirections) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.blue, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + 'This device currently has no direction. Tap the + button to add one.', + style: const TextStyle(color: Colors.blue, fontSize: 12), + ), + ), + ], + ), ), ], ); @@ -341,15 +372,28 @@ class _EditNodeSheetState extends State { selectedOperatorProfile: session.operatorProfile, selectedProfile: session.profile, currentRefinedTags: session.refinedTags, + originalNodeTags: session.originalNode.tags, ), fullscreenDialog: true, ), ); if (result != null) { - appState.updateEditSession( - operatorProfile: result.operatorProfile, - refinedTags: result.refinedTags, - ); + if (result.editedTags != null && session.profile?.isExistingTagsProfile == true) { + // Update the existing tags profile with the edited tags + final updatedProfile = session.profile!.copyWith( + tags: result.editedTags, + ); + appState.updateEditSession( + profile: updatedProfile, + operatorProfile: result.operatorProfile, + refinedTags: result.refinedTags, + ); + } else { + appState.updateEditSession( + operatorProfile: result.operatorProfile, + refinedTags: result.refinedTags, + ); + } } } @@ -539,9 +583,7 @@ class _EditNodeSheetState extends State { child: OutlinedButton.icon( onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected icon: const Icon(Icons.tune), - label: Text(session.operatorProfile != null - ? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name]) - : locService.t('editNode.refineTags')), + label: Text(locService.t('editNode.refineTags')), ), ), ), @@ -582,6 +624,15 @@ class _EditNodeSheetState extends State { } Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List submittableProfiles, LocalizationService locService) { + // Display name for the current profile - localize the existing tags profile + String getDisplayName(NodeProfile? profile) { + if (profile == null) return locService.t('editNode.selectProfile'); + if (profile.isExistingTagsProfile) { + return locService.t('editNode.existingTags'); + } + return profile.name; + } + return PopupMenuButton( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -593,7 +644,7 @@ class _EditNodeSheetState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - session.profile?.name ?? locService.t('editNode.selectProfile'), + getDisplayName(session.profile), style: TextStyle( fontSize: 16, color: session.profile != null ? null : Colors.grey.shade600, @@ -605,6 +656,14 @@ class _EditNodeSheetState extends State { ), ), itemBuilder: (context) => [ + // Existing tags profile (always first in edit mode) + PopupMenuItem( + value: 'existing_tags', + child: Text(locService.t('editNode.existingTags')), + ), + // Divider after existing tags profile + if (submittableProfiles.isNotEmpty) + const PopupMenuDivider(), // Regular profiles ...submittableProfiles.map( (profile) => PopupMenuItem( @@ -635,6 +694,10 @@ class _EditNodeSheetState extends State { onSelected: (value) { if (value == 'get_more') { _openIdentifyWebsite(context); + } else if (value == 'existing_tags') { + // Re-create and select the existing tags profile + final existingTagsProfile = NodeProfile.createExistingTagsProfile(session.originalNode); + appState.updateEditSession(profile: existingTagsProfile); } else if (value.startsWith('profile_')) { final profileId = value.substring(8); // Remove 'profile_' prefix final profile = submittableProfiles.firstWhere((p) => p.id == profileId); diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 1610139..df984a3 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -19,8 +19,11 @@ class DirectionConesBuilder { }) { final overlays = []; - // Add session cones if in add-camera mode and profile requires direction - if (session != null && session.target != null && session.profile?.requiresDirection == true) { + // Add session cones if in add-camera mode and profile requires direction AND we have directions + if (session != null && + session.target != null && + session.profile?.requiresDirection == true && + session.directions.isNotEmpty) { final sessionFov = session.profile?.fov ?? (kDirectionConeHalfAngle * 2); // Add current working direction (full opacity) @@ -50,8 +53,10 @@ class DirectionConesBuilder { } } - // Add edit session cones if in edit-camera mode and profile requires direction - if (editSession != null && editSession.profile?.requiresDirection == true) { + // Add edit session cones if in edit-camera mode and profile requires direction AND we have directions + if (editSession != null && + editSession.profile?.requiresDirection == true && + editSession.directions.isNotEmpty) { final sessionFov = editSession.profile?.fov ?? (kDirectionConeHalfAngle * 2); // Add current working direction (full opacity) diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart index 85d82e6..13f97a0 100644 --- a/lib/widgets/refine_tags_sheet.dart +++ b/lib/widgets/refine_tags_sheet.dart @@ -11,10 +11,12 @@ import 'nsi_tag_value_field.dart'; class RefineTagsResult { final OperatorProfile? operatorProfile; final Map refinedTags; + final Map? editedTags; // For existing tags profile mode RefineTagsResult({ required this.operatorProfile, required this.refinedTags, + this.editedTags, }); } @@ -24,11 +26,13 @@ class RefineTagsSheet extends StatefulWidget { this.selectedOperatorProfile, this.selectedProfile, this.currentRefinedTags, + this.originalNodeTags, }); final OperatorProfile? selectedOperatorProfile; final NodeProfile? selectedProfile; final Map? currentRefinedTags; + final Map? originalNodeTags; @override State createState() => _RefineTagsSheetState(); @@ -37,29 +41,68 @@ class RefineTagsSheet extends StatefulWidget { class _RefineTagsSheetState extends State { OperatorProfile? _selectedOperatorProfile; Map _refinedTags = {}; + + // For existing tags profile: full tag editing + late List> _editableTags; @override void initState() { super.initState(); _selectedOperatorProfile = widget.selectedOperatorProfile; _refinedTags = Map.from(widget.currentRefinedTags ?? {}); + + // Pre-populate refined tags with existing node values for empty profile tags + _prePopulateWithExistingValues(); + + // Initialize editable tags for existing tags profile + if (widget.selectedProfile?.isExistingTagsProfile == true) { + _editableTags = widget.selectedProfile!.tags.entries.toList(); + } else { + _editableTags = []; + } + } + + /// Pre-populate refined tags with existing values from the original node + void _prePopulateWithExistingValues() { + if (widget.selectedProfile == null || widget.originalNodeTags == null) return; + + // Get refinable tags (empty values in profile) + final refinableTags = _getRefinableTags(); + + // For each refinable tag, check if original node has a value + for (final tagKey in refinableTags) { + // Only pre-populate if we don't already have a refined value for this tag + if (!_refinedTags.containsKey(tagKey)) { + final existingValue = widget.originalNodeTags![tagKey]; + if (existingValue != null && existingValue.trim().isNotEmpty) { + _refinedTags[tagKey] = existingValue; + } + } + } } /// Get list of tag keys that have empty values and can be refined List _getRefinableTags() { if (widget.selectedProfile == null) return []; + if (widget.selectedProfile!.isExistingTagsProfile) return []; // Use full editing mode instead return widget.selectedProfile!.tags.entries .where((entry) => entry.value.trim().isEmpty) .map((entry) => entry.key) .toList(); } + + /// Returns true if this is the existing tags profile requiring full editing + bool get _isExistingTagsMode => widget.selectedProfile?.isExistingTagsProfile == true; @override Widget build(BuildContext context) { final appState = context.watch(); final operatorProfiles = appState.operatorProfiles; final locService = LocalizationService.instance; + + // Check if we have an existing operator profile (from the selected profile) + final hasExistingOperatorProfile = widget.selectedOperatorProfile?.isExistingOperatorProfile == true; return Scaffold( appBar: AppBar( @@ -69,14 +112,22 @@ class _RefineTagsSheetState extends State { onPressed: () => Navigator.pop(context, RefineTagsResult( operatorProfile: widget.selectedOperatorProfile, refinedTags: widget.currentRefinedTags ?? {}, + editedTags: _isExistingTagsMode ? widget.selectedProfile?.tags : null, )), ), actions: [ TextButton( - onPressed: () => Navigator.pop(context, RefineTagsResult( - operatorProfile: _selectedOperatorProfile, - refinedTags: _refinedTags, - )), + onPressed: () { + final editedTags = _isExistingTagsMode + ? Map.fromEntries(_editableTags.where((e) => e.key.isNotEmpty)) + : null; + + Navigator.pop(context, RefineTagsResult( + operatorProfile: _selectedOperatorProfile, + refinedTags: _refinedTags, + editedTags: editedTags, + )); + }, child: Text(locService.t('refineTagsSheet.done')), ), ], @@ -115,6 +166,17 @@ class _RefineTagsSheetState extends State { Card( child: Column( children: [ + // Show existing operator profile first if it exists + if (hasExistingOperatorProfile) ...[ + RadioListTile( + title: Text(locService.t('refineTagsSheet.existingOperator')), + subtitle: Text('${widget.selectedOperatorProfile!.tags.length} ${locService.t('refineTagsSheet.existingOperatorTags')}'), + value: widget.selectedOperatorProfile, + groupValue: _selectedOperatorProfile, + onChanged: (value) => setState(() => _selectedOperatorProfile = value), + ), + const Divider(height: 1), + ], RadioListTile( title: Text(locService.t('refineTagsSheet.none')), subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')), @@ -187,8 +249,10 @@ class _RefineTagsSheetState extends State { ), ], ], - // Add refineable tags section - ..._buildRefinableTagsSection(locService), + // Add refineable tags section OR existing tags editing section + ...(_isExistingTagsMode + ? _buildExistingTagsEditingSection(locService) + : _buildRefinableTagsSection(locService)), ], ), ); @@ -260,4 +324,118 @@ class _RefineTagsSheetState extends State { ), ); } + + /// Build the section for full tag editing (existing tags profile mode) + List _buildExistingTagsEditingSection(LocalizationService locService) { + return [ + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + locService.t('refineTagsSheet.existingTagsTitle'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: _addNewTag, + tooltip: 'Add new tag', + ), + ], + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('refineTagsSheet.existingTagsDescription'), + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + const SizedBox(height: 16), + if (_editableTags.isEmpty) + Text( + 'No tags defined.', + style: const TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), + ) + else + ..._editableTags.asMap().entries.map((entry) { + final index = entry.key; + final tag = entry.value; + return _buildFullTagEditor(index, tag.key, tag.value, locService); + }), + ], + ), + ), + ), + ]; + } + + /// Build a full tag editor row with key, value, and delete button + Widget _buildFullTagEditor(int index, String key, String value, LocalizationService locService) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + children: [ + // Tag key field + Expanded( + flex: 2, + child: TextFormField( + initialValue: key, + decoration: InputDecoration( + labelText: 'Key', + hintText: 'e.g., manufacturer', + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: (newKey) { + setState(() { + _editableTags[index] = MapEntry(newKey, _editableTags[index].value); + }); + }, + ), + ), + const SizedBox(width: 12), + // Tag value field (with NSI support) + Expanded( + flex: 3, + child: NSITagValueField( + key: ValueKey('${key}_${index}_edit'), + tagKey: key, + initialValue: value, + hintText: 'Tag value', + onChanged: (newValue) { + setState(() { + _editableTags[index] = MapEntry(_editableTags[index].key, newValue); + }); + }, + ), + ), + const SizedBox(width: 8), + // Delete button + IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 20), + onPressed: () => _removeTag(index), + tooltip: 'Remove tag', + ), + ], + ), + ); + } + + /// Add a new empty tag + void _addNewTag() { + setState(() { + _editableTags.add(const MapEntry('', '')); + }); + } + + /// Remove a tag by index + void _removeTag(int index) { + setState(() { + _editableTags.removeAt(index); + }); + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 914849c..fe804e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.6.1+45 # The thing after the + is the version code, incremented with each release +version: 2.6.2+46 # 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+