// PropertiesPanel — side drawer showing the currently // selected step's details. // // Lets the operator edit: // - step `id` (with collision warning before applying) // - step `use` (capability spec) // - each `with:` key/value pair (rename key, edit value, // add new key, remove key) // - delete the entire step // // The panel emits FlowGraph mutations through the controller, // which re-emits the YAML buffer + updates the canvas in one // pass. import 'package:flutter/material.dart'; import '../editor_controller.dart'; import '../l10n.dart'; import '../model/auto_layout.dart'; import '../model/flow_graph.dart'; import '../tokens.dart'; class PropertiesPanel extends StatefulWidget { final FlowEditorController controller; final FlowEditorStrings strings; final List availableCapabilities; const PropertiesPanel({ super.key, required this.controller, required this.strings, this.availableCapabilities = const [], }); @override State createState() => _PropertiesPanelState(); } class _PropertiesPanelState extends State { // Local edit buffers — committed back to the controller // on focus loss / explicit save so each keystroke doesn't // round-trip through YAML emission. final _idCtrl = TextEditingController(); final _useCtrl = TextEditingController(); // Per-with-field controllers, keyed by current field name. final Map _withCtrls = {}; String? _trackedStepId; @override void initState() { super.initState(); widget.controller.addListener(_sync); _sync(); } @override void dispose() { widget.controller.removeListener(_sync); _idCtrl.dispose(); _useCtrl.dispose(); for (final c in _withCtrls.values) { c.dispose(); } super.dispose(); } void _sync() { final selected = widget.controller.selectedStepId; if (selected != _trackedStepId) { _trackedStepId = selected; for (final c in _withCtrls.values) { c.dispose(); } _withCtrls.clear(); if (selected != null) { final step = widget.controller.graph.steps.firstWhere( (s) => s.id == selected, orElse: () => _empty, ); _idCtrl.text = step.id; _useCtrl.text = step.use; for (final entry in step.with_.entries) { _withCtrls[entry.key] = TextEditingController( text: entry.value.toString(), ); } } } if (mounted) setState(() {}); } static const _empty = FlowStep(id: '', use: ''); @override Widget build(BuildContext context) { final theme = Theme.of(context); final controller = widget.controller; // Edge selection takes priority over step selection. // The controller already enforces mutual exclusion; // the check here just routes the panel to the right // editor. final edgeKey = controller.selectedEdgeKey; if (edgeKey != null) { return _EdgeInfoView( controller: controller, strings: widget.strings, edgeKey: edgeKey, ); } final selected = controller.selectedStepId; if (selected == null) return _emptyHint(theme); // Endpoint nodes (inputs / outputs) get their own // editor — different fields, different mutations, // different vocabulary. Step panel is the default for // everything else. if (selected == AutoLayout.inputsNodeId) { return _EndpointEditor( controller: controller, strings: widget.strings, kind: _EndpointKind.inputs, ); } if (selected == AutoLayout.outputsNodeId) { return _EndpointEditor( controller: controller, strings: widget.strings, kind: _EndpointKind.outputs, ); } final step = controller.graph.steps.firstWhere( (s) => s.id == selected, orElse: () => _empty, ); if (step.id.isEmpty) return _emptyHint(theme); return Container( color: theme.colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _header(theme, step), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(FaiSpace.md), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _section(theme, widget.strings.propStepId), _idField(theme), const SizedBox(height: FaiSpace.md), _section(theme, widget.strings.propCapability), _useField(theme), const SizedBox(height: FaiSpace.md), _section(theme, widget.strings.propInputs), ..._withFields(theme), TextButton.icon( onPressed: _addWithField, icon: const Icon(Icons.add, size: 16), label: Text(widget.strings.propAddInput), ), const SizedBox(height: FaiSpace.lg), OutlinedButton.icon( onPressed: () => _deleteStep(step.id), icon: const Icon(Icons.delete_outline, size: 16), label: Text(widget.strings.propDeleteStep), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, ), ), ], ), ), ), ], ), ); } Widget _emptyHint(ThemeData theme) { return Container( color: theme.colorScheme.surface, padding: const EdgeInsets.all(FaiSpace.lg), child: Center( child: Text( widget.strings.propNoSelection, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ); } Widget _header(ThemeData theme, FlowStep step) { return Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, border: Border(bottom: BorderSide(color: theme.dividerColor)), ), child: Row( children: [ Icon( step.isApproval ? Icons.pan_tool_outlined : Icons.widgets_outlined, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.xs), Expanded( child: Text( widget.strings.propPanelTitle, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), ); } Widget _section(ThemeData theme, String label) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), ); } Widget _idField(ThemeData theme) { return TextField( controller: _idCtrl, style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( isDense: true, border: OutlineInputBorder(), ), onSubmitted: (_) => _commitId(), onTapOutside: (_) => _commitId(), ); } Widget _useField(ThemeData theme) { final caps = widget.availableCapabilities; if (caps.isEmpty) { return TextField( controller: _useCtrl, style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( isDense: true, hintText: 'e.g. text.extract@^0', border: OutlineInputBorder(), ), onSubmitted: (_) => _commitUse(), onTapOutside: (_) => _commitUse(), ); } return Autocomplete( initialValue: TextEditingValue(text: _useCtrl.text), optionsBuilder: (input) { final query = input.text.toLowerCase(); if (query.isEmpty) return caps; return caps.where((c) => c.toLowerCase().contains(query)); }, onSelected: (sel) { _useCtrl.text = sel; _commitUse(); }, fieldViewBuilder: (ctx, fieldCtrl, focus, onSubmit) { fieldCtrl.text = _useCtrl.text; return TextField( controller: fieldCtrl, focusNode: focus, style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( isDense: true, hintText: 'e.g. text.extract@^0', border: OutlineInputBorder(), ), onSubmitted: (val) { _useCtrl.text = val; _commitUse(); }, onTapOutside: (_) { _useCtrl.text = fieldCtrl.text; _commitUse(); }, ); }, ); } List _withFields(ThemeData theme) { final entries = _withCtrls.entries.toList(); return [ for (final entry in entries) Padding( padding: const EdgeInsets.only(bottom: FaiSpace.sm), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: TextField( controller: TextEditingController(text: entry.key), style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( isDense: true, border: OutlineInputBorder(), ), onSubmitted: (newKey) => _renameWithField(entry.key, newKey), ), ), const SizedBox(width: FaiSpace.xs), Expanded( child: TextField( controller: entry.value, style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( isDense: true, border: OutlineInputBorder(), ), onSubmitted: (_) => _commitWithFields(), onTapOutside: (_) => _commitWithFields(), ), ), IconButton( onPressed: () => _removeWithField(entry.key), icon: const Icon(Icons.close, size: 16), tooltip: widget.strings.propRemoveInput, visualDensity: VisualDensity.compact, ), ], ), ), ]; } void _commitId() { final selected = widget.controller.selectedStepId; if (selected == null) return; final newId = _idCtrl.text.trim(); if (newId.isEmpty || newId == selected) return; final graph = widget.controller.graph; // Reject collisions silently — UI should ideally show a // warning. For now: refuse to apply, snap controller back. if (graph.steps.any((s) => s.id == newId)) { _idCtrl.text = selected; return; } final step = graph.steps.firstWhere((s) => s.id == selected); widget.controller.applyGraphEdit( graph.withStepUpdated(selected, step.copyWith(id: newId)), ); widget.controller.selectStep(newId); } void _commitUse() { final selected = widget.controller.selectedStepId; if (selected == null) return; final graph = widget.controller.graph; final step = graph.steps.firstWhere((s) => s.id == selected); if (step.use == _useCtrl.text) return; widget.controller.applyGraphEdit( graph.withStepUpdated(selected, step.copyWith(use: _useCtrl.text)), ); } void _commitWithFields() { final selected = widget.controller.selectedStepId; if (selected == null) return; final graph = widget.controller.graph; final step = graph.steps.firstWhere((s) => s.id == selected); final newWith = { for (final entry in _withCtrls.entries) entry.key: entry.value.text, }; if (_mapsEqual(step.with_, newWith)) return; widget.controller.applyGraphEdit( graph.withStepUpdated(selected, step.copyWith(with_: newWith)), ); } void _renameWithField(String oldKey, String newKey) { final selected = widget.controller.selectedStepId; if (selected == null) return; if (newKey.isEmpty || newKey == oldKey) return; if (_withCtrls.containsKey(newKey)) return; // collision final ctrl = _withCtrls.remove(oldKey); if (ctrl != null) _withCtrls[newKey] = ctrl; _commitWithFields(); } void _addWithField() { final base = 'value'; String name = base; var i = 1; while (_withCtrls.containsKey(name)) { name = '${base}_$i'; i++; } setState(() { _withCtrls[name] = TextEditingController(text: ''); }); _commitWithFields(); } void _removeWithField(String key) { final ctrl = _withCtrls.remove(key); ctrl?.dispose(); setState(() {}); _commitWithFields(); } void _deleteStep(String id) { widget.controller.applyGraphEdit( widget.controller.graph.withStepRemoved(id), ); widget.controller.selectStep(null); } bool _mapsEqual(Map a, Map b) { if (a.length != b.length) return false; for (final entry in a.entries) { if (b[entry.key].toString() != entry.value.toString()) return false; } return true; } } /// Edge-info side of the properties panel — visible when an /// edge is selected. Shows source + target with their declared /// types, a type-compatibility indicator, and a Disconnect /// button. Helps the operator see at a glance whether a wire /// is valid; the colour cue from the canvas already telegraphs /// the same information but here we say it in words. class _EdgeInfoView extends StatelessWidget { final FlowEditorController controller; final FlowEditorStrings strings; final String edgeKey; const _EdgeInfoView({ required this.controller, required this.strings, required this.edgeKey, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final graph = controller.graph; // The edgeKey is `:` (same encoding the // hit-tester emits + the context menu uses). One edge // per target slot, so the lookup is unique. FlowEdge? edge; for (final e in graph.edges) { if ('${e.toId}:${e.toField}' == edgeKey) { edge = e; break; } } if (edge == null) { // Edge vanished while panel was open (operator deleted // it via context menu, undo, etc.). Drop the selection // and bail gracefully. WidgetsBinding.instance.addPostFrameCallback((_) { controller.selectEdge(null); }); return const SizedBox.shrink(); } final fromLabel = edge.fromKind == EdgeEndpointKind.inputs ? 'inputs.${edge.fromField}' : '${edge.fromId}.${edge.fromField}'; final toLabel = edge.toKind == EdgeEndpointKind.outputs ? 'outputs.${edge.toField}' : '${edge.toId}.${edge.toField}'; final sourceType = _sourceTypeFor(edge, graph); final targetType = _targetTypeFor(edge, graph); final typesMatch = sourceType == null || targetType == null || sourceType == targetType; return Container( color: theme.colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Header — matches the step panel's visual rhythm. Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, border: Border( bottom: BorderSide(color: theme.colorScheme.outlineVariant), ), ), child: Row( children: [ Icon(Icons.cable, size: 18, color: theme.colorScheme.primary), const SizedBox(width: FaiSpace.sm), Expanded( child: Text( strings.edgeInfoHeader, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ), IconButton( icon: const Icon(Icons.close, size: 18), visualDensity: VisualDensity.compact, tooltip: strings.edgeInfoClose, onPressed: () => controller.selectEdge(null), ), ], ), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(FaiSpace.md), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _endpointBlock( theme, label: strings.edgeInfoSource, qualified: fromLabel, type: sourceType, ), const SizedBox(height: FaiSpace.md), Center( child: Icon( Icons.arrow_downward, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), _endpointBlock( theme, label: strings.edgeInfoTarget, qualified: toLabel, type: targetType, ), const SizedBox(height: FaiSpace.lg), _typeMatchPill(theme, typesMatch, sourceType, targetType), const SizedBox(height: FaiSpace.lg), OutlinedButton.icon( onPressed: () => _disconnect(edge!), icon: const Icon(Icons.link_off, size: 16), label: Text(strings.edgeInfoDisconnect), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, ), ), ], ), ), ), ], ), ); } Widget _endpointBlock( ThemeData theme, { required String label, required String qualified, required String? type, }) { final accent = type != null ? _typeColor(type, theme) : theme.colorScheme.outline; return Container( padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 4), Text( qualified, style: theme.textTheme.titleSmall?.copyWith( fontFamily: 'monospace', fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Row( children: [ Container( width: 10, height: 10, decoration: BoxDecoration( shape: BoxShape.circle, color: accent, ), ), const SizedBox(width: 6), Text( type ?? strings.edgeInfoTypeUnknown, style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ], ), ); } Widget _typeMatchPill( ThemeData theme, bool ok, String? sourceType, String? targetType, ) { final fg = ok ? Colors.green.shade400 : theme.colorScheme.error; final label = ok ? strings.edgeInfoTypeMatch : strings.edgeInfoTypeMismatch(sourceType ?? '?', targetType ?? '?'); return Row( children: [ Icon( ok ? Icons.check_circle_outline : Icons.error_outline, size: 18, color: fg, ), const SizedBox(width: 6), Expanded( child: Text( label, style: theme.textTheme.bodySmall?.copyWith(color: fg), ), ), ], ); } String? _sourceTypeFor(FlowEdge edge, FlowGraph graph) { if (edge.fromKind == EdgeEndpointKind.inputs) { return graph.inputs[edge.fromField]?.type; } // Step output type — we don't keep ModuleSpec here in // the panel (lives on the canvas state). Return null; // the panel renders 'unknown' which is the honest answer. return null; } String? _targetTypeFor(FlowEdge edge, FlowGraph graph) { if (edge.toKind == EdgeEndpointKind.outputs) return null; return null; } Color _typeColor(String type, ThemeData theme) { final isDark = theme.brightness == Brightness.dark; switch (type) { case 'text': return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60); case 'json': return isDark ? const Color(0xFFFFB74D) : const Color(0xFFEF6C00); case 'bytes': return isDark ? const Color(0xFF4DD0E1) : const Color(0xFF00838F); case 'file': return isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); default: return theme.colorScheme.outline; } } void _disconnect(FlowEdge edge) { final graph = controller.graph; if (edge.toKind == EdgeEndpointKind.outputs) { final updated = {...graph.outputs}..remove(edge.toField); controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: updated, leadingComment: graph.leadingComment, ), ); } else { final step = graph.steps.firstWhere( (s) => s.id == edge.toId, orElse: () => const FlowStep(id: '', use: ''), ); if (step.id.isEmpty) return; final updatedWith = {...step.with_, edge.toField: ''}; controller.applyGraphEdit( graph.withStepUpdated(edge.toId, step.copyWith(with_: updatedWith)), ); } controller.selectEdge(null); } } enum _EndpointKind { inputs, outputs } /// Editor for the flow's inputs / outputs endpoints. Lets the /// operator add, rename, retype, and remove entries /// graphically — same mutations the YAML offers, but without /// requiring the operator to know YAML. class _EndpointEditor extends StatelessWidget { final FlowEditorController controller; final FlowEditorStrings strings; final _EndpointKind kind; const _EndpointEditor({ required this.controller, required this.strings, required this.kind, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( color: theme.colorScheme.surface, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _header(theme), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(FaiSpace.md), child: kind == _EndpointKind.inputs ? _InputsBody(controller: controller, strings: strings) : _OutputsBody(controller: controller, strings: strings), ), ), ], ), ); } Widget _header(ThemeData theme) { final title = kind == _EndpointKind.inputs ? strings.endpointInputsTitle : strings.endpointOutputsTitle; final body = kind == _EndpointKind.inputs ? strings.endpointInputsBody : strings.endpointOutputsBody; return Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, border: Border(bottom: BorderSide(color: theme.dividerColor)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( kind == _EndpointKind.inputs ? Icons.input : Icons.output, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.xs), Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 2), Text( body, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } } class _InputsBody extends StatelessWidget { final FlowEditorController controller; final FlowEditorStrings strings; const _InputsBody({required this.controller, required this.strings}); @override Widget build(BuildContext context) { final graph = controller.graph; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final entry in graph.inputs.entries) _InputRow( controller: controller, strings: strings, name: entry.key, input: entry.value, ), const SizedBox(height: FaiSpace.sm), TextButton.icon( onPressed: () => _addInput(), icon: const Icon(Icons.add, size: 16), label: Text(strings.endpointAdd), ), ], ); } void _addInput() { final graph = controller.graph; var name = 'input'; var i = 1; while (graph.inputs.containsKey(name)) { i++; name = 'input_$i'; } controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: { ...graph.inputs, name: const FlowInput(type: 'text'), }, steps: graph.steps, outputs: graph.outputs, leadingComment: graph.leadingComment, ), ); } } class _InputRow extends StatefulWidget { final FlowEditorController controller; final FlowEditorStrings strings; final String name; final FlowInput input; const _InputRow({ required this.controller, required this.strings, required this.name, required this.input, }); @override State<_InputRow> createState() => _InputRowState(); } class _InputRowState extends State<_InputRow> { late TextEditingController _nameCtrl; late String _trackedName; late String _type; @override void initState() { super.initState(); _trackedName = widget.name; _nameCtrl = TextEditingController(text: widget.name); _type = widget.input.type; } @override void didUpdateWidget(_InputRow old) { super.didUpdateWidget(old); if (widget.name != _trackedName) { _trackedName = widget.name; _nameCtrl.text = widget.name; } if (widget.input.type != _type) { _type = widget.input.type; } } @override void dispose() { _nameCtrl.dispose(); super.dispose(); } void _commitName() { final newName = _nameCtrl.text.trim(); if (newName.isEmpty || newName == widget.name) return; final graph = widget.controller.graph; if (graph.inputs.containsKey(newName)) { _nameCtrl.text = widget.name; return; } final renamed = {}; for (final e in graph.inputs.entries) { if (e.key == widget.name) { renamed[newName] = e.value; } else { renamed[e.key] = e.value; } } widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: renamed, steps: graph.steps, outputs: graph.outputs, leadingComment: graph.leadingComment, ), ); } void _commitType(String newType) { if (newType == widget.input.type) return; final graph = widget.controller.graph; final updated = { for (final e in graph.inputs.entries) e.key: e.key == widget.name ? FlowInput( type: newType, defaultValue: e.value.defaultValue, hint: e.value.hint, ) : e.value, }; widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: updated, steps: graph.steps, outputs: graph.outputs, leadingComment: graph.leadingComment, ), ); } void _remove() { final graph = widget.controller.graph; final next = { for (final e in graph.inputs.entries) if (e.key != widget.name) e.key: e.value, }; widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: next, steps: graph.steps, outputs: graph.outputs, leadingComment: graph.leadingComment, ), ); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: FaiSpace.sm), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: TextField( controller: _nameCtrl, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), decoration: InputDecoration( labelText: widget.strings.endpointName, isDense: true, border: const OutlineInputBorder(), ), onSubmitted: (_) => _commitName(), onTapOutside: (_) => _commitName(), ), ), const SizedBox(width: FaiSpace.xs), Expanded( flex: 2, child: DropdownButtonFormField( initialValue: _type, isDense: true, decoration: InputDecoration( labelText: widget.strings.endpointType, isDense: true, border: const OutlineInputBorder(), ), items: const [ DropdownMenuItem(value: 'text', child: Text('text')), DropdownMenuItem(value: 'bytes', child: Text('bytes')), DropdownMenuItem(value: 'json', child: Text('json')), DropdownMenuItem(value: 'file', child: Text('file')), DropdownMenuItem(value: 'number', child: Text('number')), ], onChanged: (v) { if (v == null) return; setState(() => _type = v); _commitType(v); }, ), ), IconButton( onPressed: _remove, icon: const Icon(Icons.close, size: 16), tooltip: widget.strings.endpointRemove, visualDensity: VisualDensity.compact, ), ], ), ); } } class _OutputsBody extends StatelessWidget { final FlowEditorController controller; final FlowEditorStrings strings; const _OutputsBody({required this.controller, required this.strings}); @override Widget build(BuildContext context) { final graph = controller.graph; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final entry in graph.outputs.entries) _OutputRow( controller: controller, strings: strings, name: entry.key, expression: entry.value, ), const SizedBox(height: FaiSpace.sm), TextButton.icon( onPressed: () => _addOutput(), icon: const Icon(Icons.add, size: 16), label: Text(strings.endpointAdd), ), ], ); } void _addOutput() { final graph = controller.graph; var name = 'result'; var i = 1; while (graph.outputs.containsKey(name)) { i++; name = 'result_$i'; } controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: {...graph.outputs, name: ''}, leadingComment: graph.leadingComment, ), ); } } class _OutputRow extends StatefulWidget { final FlowEditorController controller; final FlowEditorStrings strings; final String name; final String expression; const _OutputRow({ required this.controller, required this.strings, required this.name, required this.expression, }); @override State<_OutputRow> createState() => _OutputRowState(); } class _OutputRowState extends State<_OutputRow> { late TextEditingController _nameCtrl; late TextEditingController _exprCtrl; late String _trackedName; late String _trackedExpr; @override void initState() { super.initState(); _trackedName = widget.name; _trackedExpr = widget.expression; _nameCtrl = TextEditingController(text: widget.name); _exprCtrl = TextEditingController(text: widget.expression); } @override void didUpdateWidget(_OutputRow old) { super.didUpdateWidget(old); if (widget.name != _trackedName) { _trackedName = widget.name; _nameCtrl.text = widget.name; } if (widget.expression != _trackedExpr) { _trackedExpr = widget.expression; _exprCtrl.text = widget.expression; } } @override void dispose() { _nameCtrl.dispose(); _exprCtrl.dispose(); super.dispose(); } void _commitName() { final newName = _nameCtrl.text.trim(); if (newName.isEmpty || newName == widget.name) return; final graph = widget.controller.graph; if (graph.outputs.containsKey(newName)) { _nameCtrl.text = widget.name; return; } final renamed = {}; for (final e in graph.outputs.entries) { if (e.key == widget.name) { renamed[newName] = e.value; } else { renamed[e.key] = e.value; } } widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: renamed, leadingComment: graph.leadingComment, ), ); } void _commitExpr() { final newExpr = _exprCtrl.text; if (newExpr == widget.expression) return; final graph = widget.controller.graph; final updated = { for (final e in graph.outputs.entries) e.key: e.key == widget.name ? newExpr : e.value, }; widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: updated, leadingComment: graph.leadingComment, ), ); } void _remove() { final graph = widget.controller.graph; final next = { for (final e in graph.outputs.entries) if (e.key != widget.name) e.key: e.value, }; widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: next, leadingComment: graph.leadingComment, ), ); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: FaiSpace.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: TextField( controller: _nameCtrl, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), decoration: InputDecoration( labelText: widget.strings.endpointName, isDense: true, border: const OutlineInputBorder(), ), onSubmitted: (_) => _commitName(), onTapOutside: (_) => _commitName(), ), ), IconButton( onPressed: _remove, icon: const Icon(Icons.close, size: 16), tooltip: widget.strings.endpointRemove, visualDensity: VisualDensity.compact, ), ], ), const SizedBox(height: 4), TextField( controller: _exprCtrl, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), decoration: InputDecoration( labelText: widget.strings.endpointExpression, isDense: true, border: const OutlineInputBorder(), hintText: r'$step.field', ), onSubmitted: (_) => _commitExpr(), onTapOutside: (_) => _commitExpr(), ), ], ), ); } }