Pass through all extraneous tags on an existing node, always. Strip out special case for "existing tags" profile, replace with an empty temp profile so all can be treated the same.

This commit is contained in:
stopflock
2026-02-05 13:21:54 -06:00
parent 5df16f376d
commit c50d43e00c
8 changed files with 298 additions and 104 deletions

View File

@@ -104,8 +104,9 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Temp. "existing" profile should pick up FOV if available from each direction
- Imperial units
- Pass through tags on existing nodes which are not included in selected profile to the refine tags page, much like we do for all tags when "existing tags" profile is selected
- Clear search box after selecting first nav point
- Make submission guide scarier
- Tile cache trimming? Does fluttermap handle?
- Filter NSI suggestions based on what has already been typed in
@@ -121,6 +122,7 @@ cp lib/keys.dart.example lib/keys.dart
- Import default profiles from the website to capture changes without pushing an update?
### Future Features & Wishlist
- Tap direction slider to enter integer directly
- Tap pending queue item to edit again before submitting
- Optional reason message when deleting
- Update offline area data while browsing?

View File

@@ -445,6 +445,7 @@ class AppState extends ChangeNotifier {
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) {
_sessionState.updateSession(
@@ -453,6 +454,7 @@ class AppState extends ChangeNotifier {
operatorProfile: operatorProfile,
target: target,
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
);
@@ -469,6 +471,7 @@ class AppState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) {
_sessionState.updateEditSession(
@@ -478,6 +481,7 @@ class AppState extends ChangeNotifier {
target: target,
extractFromWay: extractFromWay,
refinedTags: refinedTags,
additionalExistingTags: additionalExistingTags,
changesetComment: changesetComment,
);

View File

@@ -266,22 +266,14 @@ class NodeProfile {
@override
int get hashCode => id.hashCode;
/// Create a temporary profile representing the existing tags on a node (minus direction and operator)
/// Create a temporary empty profile for editing existing nodes
/// Used as the default "<Existing tags>" option when editing nodes
/// All existing tags will flow through as additionalExistingTags
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}',
id: 'temp-empty-${node.id}',
name: '<Existing tags>', // Will be localized in UI
tags: tagsWithoutSpecial,
tags: {}, // Completely empty - all existing tags become additional
builtin: false,
requiresDirection: true,
submittable: true,
@@ -289,7 +281,6 @@ class NodeProfile {
);
}
/// Returns true if this is a temporary "existing tags" profile
bool get isExistingTagsProfile => id.startsWith('temp-no-change-');
}

View File

@@ -22,6 +22,7 @@ class PendingUpload {
final NodeProfile? profile;
final OperatorProfile? operatorProfile;
final Map<String, String> refinedTags; // User-selected values for empty profile tags
final Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
final String changesetComment; // User-editable changeset comment
final UploadMode uploadMode; // Capture upload destination when queued
final UploadOperation operation; // Type of operation: create, modify, or delete
@@ -46,6 +47,7 @@ class PendingUpload {
this.profile,
this.operatorProfile,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
required this.changesetComment,
required this.uploadMode,
required this.operation,
@@ -64,6 +66,7 @@ class PendingUpload {
this.nodeSubmissionAttempts = 0,
this.lastNodeSubmissionAttemptAt,
}) : refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {},
assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation == UploadOperation.create) || (originalNodeId != null),
@@ -224,7 +227,7 @@ class PendingUpload {
return DateTime.now().isAfter(nextRetryTime);
}
// Get combined tags from node profile, operator profile, and refined tags
// Get combined tags from node profile, operator profile, refined tags, and additional existing tags
Map<String, String> getCombinedTags() {
// Deletions don't need tags
if (operation == UploadOperation.delete || profile == null) {
@@ -233,15 +236,21 @@ class PendingUpload {
final tags = Map<String, String>.from(profile!.tags);
// Add additional existing tags first (these have lower precedence)
tags.addAll(additionalExistingTags);
// Apply profile tags again to ensure they take precedence over additional existing tags
tags.addAll(profile!.tags);
// Apply refined tags (these fill in empty values from the profile)
for (final entry in refinedTags.entries) {
// Only apply refined tags if the profile tag value is empty
if (tags.containsKey(entry.key) && tags[entry.key]?.trim().isEmpty == true) {
if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) {
tags[entry.key] = entry.value;
}
}
// Add operator profile tags (they override node profile tags if there are conflicts)
// Add operator profile tags (they override everything if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
@@ -271,6 +280,7 @@ class PendingUpload {
'profile': profile?.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'refinedTags': refinedTags,
'additionalExistingTags': additionalExistingTags,
'changesetComment': changesetComment,
'uploadMode': uploadMode.index,
'operation': operation.index,
@@ -302,6 +312,9 @@ class PendingUpload {
refinedTags: j['refinedTags'] != null
? Map<String, String>.from(j['refinedTags'])
: {}, // Default empty map for legacy entries
additionalExistingTags: j['additionalExistingTags'] != null
? Map<String, String>.from(j['additionalExistingTags'])
: {}, // Default empty map for legacy entries
changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]

View File

@@ -14,6 +14,7 @@ class AddNodeSession {
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
Map<String, String> refinedTags; // User-selected values for empty profile tags
Map<String, String> additionalExistingTags; // For consistency (always empty for new nodes)
String changesetComment; // User-editable changeset comment
AddNodeSession({
@@ -22,10 +23,12 @@ class AddNodeSession {
this.operatorProfile,
this.target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {}, // Always empty for new nodes
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
@@ -50,6 +53,7 @@ class EditNodeSession {
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
bool extractFromWay; // True if user wants to extract this constrained node
Map<String, String> refinedTags; // User-selected values for empty profile tags
Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
String changesetComment; // User-editable changeset comment
EditNodeSession({
@@ -61,10 +65,12 @@ class EditNodeSession {
required this.target,
this.extractFromWay = false,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) : directions = [initialDirection],
currentDirectionIndex = 0,
refinedTags = refinedTags ?? {},
additionalExistingTags = additionalExistingTags ?? {},
changesetComment = changesetComment ?? '';
// Slider always shows the current direction being edited
@@ -97,7 +103,7 @@ class SessionState extends ChangeNotifier {
}
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles, List<OperatorProfile> operatorProfiles) {
// Always create and pre-select the temporary "existing tags" profile
// Always create and pre-select the temporary "existing tags" profile (now empty)
final existingTagsProfile = NodeProfile.createExistingTagsProfile(node);
// Detect and store operator profile (persists across profile changes)
@@ -108,6 +114,13 @@ class SessionState extends ChangeNotifier {
final initialDirection = existingDirections.isNotEmpty ? existingDirections.first : 0.0;
final originalHadDirections = existingDirections.isNotEmpty;
// Since the "existing tags" profile is now empty, all existing node tags
// (minus special ones) should go into additionalExistingTags
final initialAdditionalTags = _calculateAdditionalExistingTags(existingTagsProfile, node);
// Auto-populate refined tags (empty profile means no refined tags initially)
final initialRefinedTags = _calculateRefinedTags(existingTagsProfile, node);
_editSession = EditNodeSession(
originalNode: node,
originalHadDirections: originalHadDirections,
@@ -115,6 +128,8 @@ class SessionState extends ChangeNotifier {
operatorProfile: _detectedOperatorProfile,
initialDirection: initialDirection,
target: node.coord,
additionalExistingTags: initialAdditionalTags,
refinedTags: initialRefinedTags,
changesetComment: 'Update a surveillance node', // Default comment for existing tags profile
);
@@ -135,12 +150,80 @@ class SessionState extends ChangeNotifier {
return true;
}
/// Calculate additional existing tags for a given profile change
Map<String, String> _calculateAdditionalExistingTags(NodeProfile? newProfile, OsmNode originalNode) {
final additionalTags = <String, String>{};
// Skip if no profile
if (newProfile == null) {
return additionalTags;
}
// Get tags from the original node that are not in the selected profile
final profileTagKeys = newProfile.tags.keys.toSet();
final originalTags = originalNode.tags;
for (final entry in originalTags.entries) {
final key = entry.key;
final value = entry.value;
// Skip tags that are handled elsewhere
if (_shouldSkipTag(key)) continue;
// Skip tags that exist in the selected profile
if (profileTagKeys.contains(key)) continue;
// Include this tag as an additional existing tag
additionalTags[key] = value;
}
return additionalTags;
}
/// Auto-populate refined tags with existing values from the original node
Map<String, String> _calculateRefinedTags(NodeProfile? profile, OsmNode originalNode) {
final refinedTags = <String, String>{};
if (profile == null) return refinedTags;
// For each empty-value tag in the profile, check if original node has a value
for (final entry in profile.tags.entries) {
final tagKey = entry.key;
final profileValue = entry.value;
// Only auto-populate if profile tag value is empty
if (profileValue.trim().isEmpty) {
final existingValue = originalNode.tags[tagKey];
if (existingValue != null && existingValue.trim().isNotEmpty) {
refinedTags[tagKey] = existingValue;
}
}
}
return refinedTags;
}
/// Check if a tag should be skipped from additional existing tags
bool _shouldSkipTag(String key) {
// Skip direction tags (handled separately)
if (key == 'direction' || key == 'camera:direction') return true;
// Skip operator tags (handled by operator profile)
if (key == 'operator' || key.startsWith('operator:')) return true;
// Skip internal cache tags
if (key.startsWith('_')) return true;
return false;
}
void updateSession({
double? directionDeg,
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) {
if (_session == null) return;
@@ -171,6 +254,10 @@ class SessionState extends ChangeNotifier {
_session!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (additionalExistingTags != null) {
_session!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
dirty = true;
}
if (changesetComment != null) {
_session!.changesetComment = changesetComment;
dirty = true;
@@ -185,6 +272,7 @@ class SessionState extends ChangeNotifier {
LatLng? target,
bool? extractFromWay,
Map<String, String>? refinedTags,
Map<String, String>? additionalExistingTags,
String? changesetComment,
}) {
if (_editSession == null) return;
@@ -210,6 +298,18 @@ class SessionState extends ChangeNotifier {
_editSession!.operatorProfile = _detectedOperatorProfile;
}
// Calculate additional existing tags for non-existing-tags profiles
// Only do this if additionalExistingTags wasn't explicitly provided
if (additionalExistingTags == null) {
_editSession!.additionalExistingTags = _calculateAdditionalExistingTags(profile, _editSession!.originalNode);
}
// Auto-populate refined tags with existing values for empty profile tags
// Only do this if refinedTags wasn't explicitly provided
if (refinedTags == null) {
_editSession!.refinedTags = _calculateRefinedTags(profile, _editSession!.originalNode);
}
// Regenerate changeset comment when profile changes
final operation = _editSession!.extractFromWay ? UploadOperation.extract : UploadOperation.modify;
_editSession!.changesetComment = _generateDefaultChangesetComment(
@@ -242,6 +342,10 @@ class SessionState extends ChangeNotifier {
_editSession!.refinedTags = Map<String, String>.from(refinedTags);
dirty = true;
}
if (additionalExistingTags != null) {
_editSession!.additionalExistingTags = Map<String, String>.from(additionalExistingTags);
dirty = true;
}
if (changesetComment != null) {
_editSession!.changesetComment = changesetComment;
dirty = true;
@@ -350,9 +454,9 @@ class SessionState extends ChangeNotifier {
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;
// Minimum = 0 only if original node had no directions
// Allow preserving the original state (directionless nodes can stay directionless)
return _editSession!.originalHadDirections ? 1 : 0;
}
/// Check if remove direction button should be enabled for edit session

View File

@@ -130,6 +130,7 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags, // Always empty for new nodes
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: UploadOperation.create,
@@ -188,6 +189,7 @@ class UploadQueueState extends ChangeNotifier {
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags,
changesetComment: session.changesetComment,
uploadMode: uploadMode,
operation: operation,

View File

@@ -154,28 +154,40 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
/// Check if the edit session has any actual changes compared to the original node
bool _hasActualChanges(EditNodeSession session) {
debugPrint('EditNodeSheet: Checking for actual changes...');
// Extract operation is always a change
if (session.extractFromWay) return true;
if (session.extractFromWay) {
debugPrint('EditNodeSheet: Extract operation detected - changes found');
return true;
}
// Check location change
const double tolerance = 0.0000001; // ~1cm precision
if ((session.target.latitude - session.originalNode.coord.latitude).abs() > tolerance ||
(session.target.longitude - session.originalNode.coord.longitude).abs() > tolerance) {
debugPrint('EditNodeSheet: Location change detected - changes found');
return true;
}
// Check direction changes
if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) {
debugPrint('EditNodeSheet: Direction change detected - changes found');
return true;
}
// Check tag changes (including operator profile)
final originalTags = session.originalNode.tags;
final newTags = _getSessionCombinedTags(session);
debugPrint('EditNodeSheet: Original tags: $originalTags');
debugPrint('EditNodeSheet: New combined tags: $newTags');
if (!_tagsEqual(originalTags, newTags)) {
debugPrint('EditNodeSheet: Tag changes detected - changes found');
return true;
}
debugPrint('EditNodeSheet: No changes detected');
return false;
}
@@ -238,9 +250,11 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
profile: session.profile,
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags, // Include additional existing tags!
changesetComment: session.changesetComment, // Required parameter
uploadMode: UploadMode.production, // Mode doesn't matter for tag combination
operation: UploadOperation.modify,
originalNodeId: session.originalNode.id, // Required for modify operations
);
return tempUpload.getCombinedTags();
@@ -491,6 +505,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
selectedOperatorProfile: session.operatorProfile,
selectedProfile: session.profile,
currentRefinedTags: session.refinedTags,
currentAdditionalExistingTags: session.additionalExistingTags,
originalNodeTags: session.originalNode.tags,
operation: session.extractFromWay ? UploadOperation.extract : UploadOperation.modify,
),
@@ -498,24 +513,17 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
),
);
if (result != null) {
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,
changesetComment: result.changesetComment,
);
} else {
appState.updateEditSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
changesetComment: result.changesetComment,
);
}
debugPrint('EditNodeSheet: Updating session from refine tags result');
debugPrint('EditNodeSheet: Profile: ${session.profile?.name}');
debugPrint('EditNodeSheet: AdditionalExistingTags: ${result.additionalExistingTags}');
debugPrint('EditNodeSheet: Current session additionalExistingTags: ${session.additionalExistingTags}');
appState.updateEditSession(
operatorProfile: result.operatorProfile,
refinedTags: result.refinedTags,
additionalExistingTags: result.additionalExistingTags,
changesetComment: result.changesetComment,
);
}
}
@@ -749,7 +757,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
// 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) {
if (profile.id.startsWith('temp-empty-')) {
return locService.t('editNode.existingTags');
}
return profile.name;

View File

@@ -11,13 +11,13 @@ 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
final Map<String, String>? additionalExistingTags; // For tags that exist on node but not in selected profile
final String changesetComment; // User-editable changeset comment
RefineTagsResult({
required this.operatorProfile,
required this.refinedTags,
this.editedTags,
this.additionalExistingTags,
required this.changesetComment,
});
}
@@ -28,6 +28,7 @@ class RefineTagsSheet extends StatefulWidget {
this.selectedOperatorProfile,
this.selectedProfile,
this.currentRefinedTags,
this.currentAdditionalExistingTags,
this.originalNodeTags,
required this.operation,
});
@@ -35,6 +36,7 @@ class RefineTagsSheet extends StatefulWidget {
final OperatorProfile? selectedOperatorProfile;
final NodeProfile? selectedProfile;
final Map<String, String>? currentRefinedTags;
final Map<String, String>? currentAdditionalExistingTags;
final Map<String, String>? originalNodeTags;
final UploadOperation operation;
@@ -46,8 +48,10 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
Map<String, String> _refinedTags = {};
// For existing tags profile: full tag editing
late List<MapEntry<String, String>> _editableTags;
// For additional existing tags (tags on node but not in profile)
late List<MapEntry<String, String>> _additionalExistingTags;
// Changeset comment editing
late final TextEditingController _commentController;
@@ -58,15 +62,13 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
_selectedOperatorProfile = widget.selectedOperatorProfile;
_refinedTags = Map<String, String>.from(widget.currentRefinedTags ?? {});
// Pre-populate refined tags with existing node values for empty profile tags
_prePopulateWithExistingValues();
// Note: Pre-population is now handled by SessionState when profile changes
// _refinedTags is already initialized with the session's refinedTags above
// Initialize editable tags for existing tags profile
if (widget.selectedProfile?.isExistingTagsProfile == true) {
_editableTags = widget.selectedProfile!.tags.entries.toList();
} else {
_editableTags = [];
}
// Initialize additional existing tags (tags on node but not in profile)
_initializeAdditionalExistingTags();
// Initialize changeset comment with default
final defaultComment = AppState.generateDefaultChangesetComment(
@@ -82,29 +84,11 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
super.dispose();
}
/// 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)
@@ -112,8 +96,82 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
.toList();
}
/// Returns true if this is the existing tags profile requiring full editing
bool get _isExistingTagsMode => widget.selectedProfile?.isExistingTagsProfile == true;
/// Initialize additional existing tags (tags that exist on the node but not in the selected profile)
void _initializeAdditionalExistingTags() {
// Use the additional existing tags calculated by SessionState when profile changed
if (widget.currentAdditionalExistingTags != null) {
_additionalExistingTags = widget.currentAdditionalExistingTags!.entries.toList();
debugPrint('RefineTagsSheet: Loaded ${_additionalExistingTags.length} additional existing tags from session');
return;
}
// Fallback: calculate them here if not provided (shouldn't normally happen)
_additionalExistingTags = [];
// Skip if we don't have the required data
if (widget.originalNodeTags == null || widget.selectedProfile == null) {
return;
}
// Get tags from the original node that are not in the selected profile
final profileTagKeys = widget.selectedProfile!.tags.keys.toSet();
final originalTags = widget.originalNodeTags!;
for (final entry in originalTags.entries) {
final key = entry.key;
final value = entry.value;
// Skip tags that are handled elsewhere
if (_shouldSkipTag(key)) continue;
// Skip tags that exist in the selected profile
if (profileTagKeys.contains(key)) continue;
// Include this tag as an additional existing tag
_additionalExistingTags.add(MapEntry(key, value));
}
debugPrint('RefineTagsSheet: Fallback calculated ${_additionalExistingTags.length} additional existing tags');
}
/// Check if a tag should be skipped from additional existing tags
bool _shouldSkipTag(String key) {
// Skip direction tags (handled separately)
if (key == 'direction' || key == 'camera:direction') return true;
// Skip operator tags (handled by operator profile)
if (key == 'operator' || key.startsWith('operator:')) return true;
// Skip internal cache tags
if (key.startsWith('_')) return true;
return false;
}
/// Returns true if we should show the additional existing tags section
bool get _shouldShowAdditionalExistingTags => _hasAdditionalExistingTagsToManage;
/// Returns true if we have additional existing tags to manage (even if user deleted them all)
bool get _hasAdditionalExistingTagsToManage {
// Check if we originally had additional existing tags OR if user has added new ones
if (widget.currentAdditionalExistingTags != null && widget.currentAdditionalExistingTags!.isNotEmpty) {
return true; // We loaded some from the session
}
// Fallback: check if we calculated any from the original node
if (widget.originalNodeTags != null && widget.selectedProfile != null) {
final profileTagKeys = widget.selectedProfile!.tags.keys.toSet();
final originalTags = widget.originalNodeTags!;
for (final entry in originalTags.entries) {
if (_shouldSkipTag(entry.key)) continue;
if (profileTagKeys.contains(entry.key)) continue;
return true; // Found at least one additional existing tag
}
}
return false;
}
@override
Widget build(BuildContext context) {
@@ -132,21 +190,28 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
onPressed: () => Navigator.pop(context, RefineTagsResult(
operatorProfile: widget.selectedOperatorProfile,
refinedTags: widget.currentRefinedTags ?? {},
editedTags: _isExistingTagsMode ? widget.selectedProfile?.tags : null,
additionalExistingTags: _hasAdditionalExistingTagsToManage
? Map<String, String>.fromEntries(_additionalExistingTags.where((e) => e.key.isNotEmpty))
: null,
changesetComment: _commentController.text,
)),
),
actions: [
TextButton(
onPressed: () {
final editedTags = _isExistingTagsMode
? Map<String, String>.fromEntries(_editableTags.where((e) => e.key.isNotEmpty))
final additionalTags = _hasAdditionalExistingTagsToManage
? Map<String, String>.fromEntries(_additionalExistingTags.where((e) => e.key.isNotEmpty))
: null;
debugPrint('RefineTagsSheet: Returning result');
debugPrint('RefineTagsSheet: additionalTags: $additionalTags');
debugPrint('RefineTagsSheet: _additionalExistingTags: $_additionalExistingTags');
debugPrint('RefineTagsSheet: _shouldShowAdditionalExistingTags: $_shouldShowAdditionalExistingTags');
Navigator.pop(context, RefineTagsResult(
operatorProfile: _selectedOperatorProfile,
refinedTags: _refinedTags,
editedTags: editedTags,
additionalExistingTags: additionalTags,
changesetComment: _commentController.text,
));
},
@@ -276,10 +341,13 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
),
],
],
// Add refineable tags section OR existing tags editing section
...(_isExistingTagsMode
? _buildExistingTagsEditingSection(locService)
: _buildRefinableTagsSection(locService)),
// Add refineable tags section
..._buildRefinableTagsSection(locService),
// Add additional existing tags section
...(_shouldShowAdditionalExistingTags
? _buildAdditionalExistingTagsSection(locService)
: []),
// Changeset comment section
const SizedBox(height: 16),
@@ -371,20 +439,22 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
);
}
/// Build the section for full tag editing (existing tags profile mode)
List<Widget> _buildExistingTagsEditingSection(LocalizationService locService) {
/// Build the section for additional existing tags (tags on node but not in profile)
List<Widget> _buildAdditionalExistingTagsSection(LocalizationService locService) {
return [
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
locService.t('refineTagsSheet.existingTagsTitle'),
'Additional Existing Tags',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: _addNewTag,
onPressed: _addNewAdditionalTag,
tooltip: 'Add new tag',
),
],
@@ -397,20 +467,20 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('refineTagsSheet.existingTagsDescription'),
'These tags exist on the original node but are not part of the selected profile. You can edit or remove them.',
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 16),
if (_editableTags.isEmpty)
if (_additionalExistingTags.isEmpty)
Text(
'No tags defined.',
'No additional existing tags.',
style: const TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
)
else
..._editableTags.asMap().entries.map((entry) {
..._additionalExistingTags.asMap().entries.map((entry) {
final index = entry.key;
final tag = entry.value;
return _buildFullTagEditor(index, tag.key, tag.value, locService);
return _buildAdditionalTagEditor(index, tag.key, tag.value, locService);
}),
],
),
@@ -419,8 +489,8 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
];
}
/// Build a full tag editor row with key, value, and delete button
Widget _buildFullTagEditor(int index, String key, String value, LocalizationService locService) {
/// Build a tag editor row for additional existing tags
Widget _buildAdditionalTagEditor(int index, String key, String value, LocalizationService locService) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
@@ -438,7 +508,7 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
),
onChanged: (newKey) {
setState(() {
_editableTags[index] = MapEntry(newKey, _editableTags[index].value);
_additionalExistingTags[index] = MapEntry(newKey, _additionalExistingTags[index].value);
});
},
),
@@ -448,13 +518,13 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
Expanded(
flex: 3,
child: NSITagValueField(
key: ValueKey('${key}_${index}_edit'),
key: ValueKey('${key}_${index}_additional'),
tagKey: key,
initialValue: value,
hintText: 'Tag value',
onChanged: (newValue) {
setState(() {
_editableTags[index] = MapEntry(_editableTags[index].key, newValue);
_additionalExistingTags[index] = MapEntry(_additionalExistingTags[index].key, newValue);
});
},
),
@@ -463,7 +533,7 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
// Delete button
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 20),
onPressed: () => _removeTag(index),
onPressed: () => _removeAdditionalTag(index),
tooltip: 'Remove tag',
),
],
@@ -471,17 +541,17 @@ class _RefineTagsSheetState extends State<RefineTagsSheet> {
);
}
/// Add a new empty tag
void _addNewTag() {
/// Add a new empty additional tag
void _addNewAdditionalTag() {
setState(() {
_editableTags.add(const MapEntry('', ''));
_additionalExistingTags.add(const MapEntry('', ''));
});
}
/// Remove a tag by index
void _removeTag(int index) {
/// Remove an additional tag by index
void _removeAdditionalTag(int index) {
setState(() {
_editableTags.removeAt(index);
_additionalExistingTags.removeAt(index);
});
}
}