"existing tags" temp profile when editing, "existing operator" profile when such tags exist, full editing of existing nodes via refine tags

This commit is contained in:
stopflock
2026-02-01 14:50:47 -06:00
parent 1dd0258c0b
commit ba3b844c1e
19 changed files with 521 additions and 100 deletions

View File

@@ -104,7 +104,8 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Default profile selection "<no change>" 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

View File

@@ -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 '<Existing tags>' profile when editing nodes; preserves current device tags while allowing direction and location edits",
"• New '<Existing operator>' 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",

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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": "<Vorhandene Tags>"
},
"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": "<Vorhandener Betreiber>",
"existingOperatorTags": "vorhandene Betreiber-Tags"
},
"layerSelector": {
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",

View File

@@ -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": "<Existing tags>"
},
"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": "<Existing operator>",
"existingOperatorTags": "existing operator tags"
},
"layerSelector": {
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",

View File

@@ -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": "<Etiquetas existentes>"
},
"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": "<Operador existente>",
"existingOperatorTags": "etiquetas de operador existentes"
},
"layerSelector": {
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",

View File

@@ -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": "<Balises existantes>"
},
"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": "<Opérateur existant>",
"existingOperatorTags": "balises d'opérateur existantes"
},
"layerSelector": {
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",

View File

@@ -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": "<Tag esistenti>"
},
"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": "<Operatore esistente>",
"existingOperatorTags": "tag operatore esistenti"
},
"layerSelector": {
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",

View File

@@ -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": "<Tags existentes>"
},
"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": "<Operador existente>",
"existingOperatorTags": "tags de operador existentes"
},
"layerSelector": {
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",

View File

@@ -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": "在下载离线区域时无法更改瓦片类型",

View File

@@ -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 "<Existing tags>" option when editing nodes
static NodeProfile createExistingTagsProfile(OsmNode node) {
final tagsWithoutSpecial = Map<String, String>.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: '<Existing tags>', // 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-');
}

View File

@@ -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<OperatorProfile> 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'] ?? '<existing>';
return OperatorProfile(
id: 'temp-existing-operator-${node.id}',
name: operatorName,
tags: operatorTags,
);
}
/// Check if two tag maps are identical
static bool _tagsMatch(Map<String, String> tags1, Map<String, String> 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<String, String> _extractOperatorTags(Map<String, String> tags) {
final operatorTags = <String, String>{};
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-');
}

View File

@@ -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<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles, List<OperatorProfile> 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 : <double>[];
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<double>.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;
}
}
}

View File

@@ -578,9 +578,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
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')),
),
),
),

View File

@@ -153,7 +153,9 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
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<EditNodeSheet> {
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
if (requiresDirection && hasDirections) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
@@ -195,7 +197,12 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
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<EditNodeSheet> {
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<EditNodeSheet> {
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<EditNodeSheet> {
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<EditNodeSheet> {
),
],
),
)
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<EditNodeSheet> {
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<EditNodeSheet> {
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<EditNodeSheet> {
}
Widget _buildProfileDropdown(BuildContext context, AppState appState, EditNodeSession session, List<NodeProfile> 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<String>(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -593,7 +644,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
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<EditNodeSheet> {
),
),
itemBuilder: (context) => [
// Existing tags profile (always first in edit mode)
PopupMenuItem<String>(
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<String>(
@@ -635,6 +694,10 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
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);

View File

@@ -19,8 +19,11 @@ class DirectionConesBuilder {
}) {
final overlays = <Polygon>[];
// 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)

View File

@@ -11,10 +11,12 @@ import 'nsi_tag_value_field.dart';
class RefineTagsResult {
final OperatorProfile? operatorProfile;
final Map<String, String> refinedTags;
final Map<String, String>? 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<String, String>? currentRefinedTags;
final Map<String, String>? originalNodeTags;
@override
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
@@ -37,29 +41,68 @@ class RefineTagsSheet extends StatefulWidget {
class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
Map<String, String> _refinedTags = {};
// For existing tags profile: full tag editing
late List<MapEntry<String, String>> _editableTags;
@override
void initState() {
super.initState();
_selectedOperatorProfile = widget.selectedOperatorProfile;
_refinedTags = Map<String, String>.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<String> _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<AppState>();
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<RefineTagsSheet> {
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<String, String>.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<RefineTagsSheet> {
Card(
child: Column(
children: [
// Show existing operator profile first if it exists
if (hasExistingOperatorProfile) ...[
RadioListTile<OperatorProfile?>(
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<OperatorProfile?>(
title: Text(locService.t('refineTagsSheet.none')),
subtitle: Text(locService.t('refineTagsSheet.noAdditionalOperatorTags')),
@@ -187,8 +249,10 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
),
],
],
// 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<RefineTagsSheet> {
),
);
}
/// Build the section for full tag editing (existing tags profile mode)
List<Widget> _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);
});
}
}

View File

@@ -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+