"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
+1 -3
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')),
),
),
),
+82 -19
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);
+9 -4
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)
+184 -6
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);
});
}
}