mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
"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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "在下载离线区域时无法更改瓦片类型",
|
||||
|
||||
@@ -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-');
|
||||
}
|
||||
|
||||
|
||||
@@ -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-');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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+
|
||||
|
||||
Reference in New Issue
Block a user