diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index e34eec2..35b33c3 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -612,17 +612,44 @@ class _Toolbar extends StatelessWidget { style: theme.textTheme.titleSmall?.copyWith( fontFamily: 'monospace', fontWeight: FontWeight.w600, + // Dirty file name renders in the primary accent + // so the operator sees "this file has unsaved + // changes" at a glance even when their eye is + // elsewhere in the UI. + color: dirty ? theme.colorScheme.primary : null, ), ), - if (dirty) - Padding( - padding: const EdgeInsets.only(left: FaiSpace.xs), - child: Icon( - Icons.circle, - size: 8, - color: theme.colorScheme.primary, + if (dirty) ...[ + const SizedBox(width: FaiSpace.xs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.fiber_manual_record, + size: 8, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + strings.unsaved, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), + ], const Spacer(), FilledButton.tonalIcon( onPressed: onNew, diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 97e0a89..f85ed7a 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -109,4 +109,26 @@ class FlowEditorStrings { ); String get runOutputs => _t('Outputs', 'Ausgaben'); String get runChooseFile => _t('Choose file…', 'Datei wählen…'); + + // Toolbar / dirty marker. + String get unsaved => _t('unsaved', 'ungespeichert'); + + // Inputs / outputs editor (when an endpoint node is selected). + String get endpointInputsTitle => _t('Flow inputs', 'Flow-Eingaben'); + String get endpointOutputsTitle => _t('Flow outputs', 'Flow-Ausgaben'); + String get endpointInputsBody => _t( + 'What the operator supplies at run time.', + 'Was der Bediener zur Laufzeit bereitstellt.', + ); + String get endpointOutputsBody => _t( + 'What the flow exposes when it completes.', + 'Was der Flow nach Abschluss bereitstellt.', + ); + String get endpointName => _t('Name', 'Name'); + String get endpointType => _t('Type', 'Typ'); + String get endpointDefault => _t('Default', 'Standard'); + String get endpointHint => _t('Hint', 'Hinweis'); + String get endpointExpression => _t('Expression', 'Ausdruck'); + String get endpointAdd => _t('Add', 'Hinzufügen'); + String get endpointRemove => _t('Remove', 'Entfernen'); } diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 8a5321a..3536a01 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -343,6 +343,7 @@ class _FlowCanvasState extends State { required NodeVisualKind kind, required List labels, }) { + final selected = widget.controller.selectedStepId == nodeId; return Positioned( left: pos.x, top: pos.y, @@ -351,7 +352,12 @@ class _FlowCanvasState extends State { title: title, kind: kind, inputPortLabels: labels, - selected: false, + selected: selected, + // Endpoints are selectable too — selecting opens + // the inputs / outputs editor in the properties + // panel so the operator can rename, retype, or add + // entries graphically instead of editing YAML. + onTap: () => widget.controller.selectStep(nodeId), onDrag: (delta) => _applyDrag( nodeId, pos, diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 111dace..95f78eb 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -19,36 +19,55 @@ import '../model/flow_graph.dart'; import '../tokens.dart'; /// Geometry constants the canvas + edge painter rely on to -/// place ports without measuring widgets at runtime. Changing -/// any of these requires reviewing edge_painter.dart. +/// place ports without measuring widgets at runtime. Every +/// number here must match the actual widget layout produced +/// by FlowNode.build — when these drift, the connection +/// dots stop aligning with the inline port-labels and edges +/// look like they're attached to nothing in particular. +/// +/// Layout reads top-to-bottom: +/// - header band (titleHeight + subtitleHeight + chrome) +/// - bodyTopPad +/// - N × portRowHeight (each a row that vertically centres +/// its content) +/// - bodyBottomPad class NodeGeometry { static const double width = 220; - static const double headerHeight = 36; + // Header band is constant whether the node has a subtitle + // or not. Endpoint nodes (inputs / outputs) render an empty + // subtitle slot — that keeps port-row Y-offsets identical + // across every node kind, which is what the port-dot + // positioning relies on. + static const double titleLineHeight = 22; + static const double subtitleLineHeight = 16; + static const double headerHeight = titleLineHeight + subtitleLineHeight + 10; static const double portRowHeight = 22; - static const double bottomPadding = 12; - static const double portRadius = 6; - // Offset from the card's outer edge for the port's center. - // Half-out so the dot visually "attaches" to the side. - static const double portInset = 0; + static const double bodyTopPad = 6; + static const double bodyBottomPad = 10; + static const double portDotSize = 12; - /// Total height for a step node given its with-field count. - /// Approval and inputs/outputs variants use the same maths - /// so the canvas can place every node identically. + /// Total card height for a node with [portCount] inputs. static double heightFor(int portCount) { - final base = headerHeight + bottomPadding; - final ports = portCount * portRowHeight; - return base + ports + 8; + return headerHeight + + bodyTopPad + + portCount * portRowHeight + + bodyBottomPad; } - /// Y offset of an input port's centre, measured from the - /// top of the card. The first input port sits below the - /// header with a small gap. + /// Y offset (from the card's top edge) where the input + /// port at row [index] is vertically centred. Match this + /// exactly with the inline 6-px dot inside FlowNode._body. static double inputPortY(int index) { - return headerHeight + 6 + index * portRowHeight + portRowHeight / 2; + return headerHeight + + bodyTopPad + + index * portRowHeight + + portRowHeight / 2; } /// Y offset of the single output port. Vertically centred - /// in the header band so the edge entry feels balanced. + /// in the header so the edge entry feels balanced and so + /// "the step's primary output" reads as a peer of the + /// title rather than buried under the with-fields. static double outputPortY() { return headerHeight / 2; } @@ -136,6 +155,10 @@ class FlowNode extends StatelessWidget { Widget _header(ThemeData theme, Color accent) { return Container( + // Fixed header height (regardless of whether subtitle + // is present) — so the body's port-row Y offsets are + // identical across every node kind. Endpoint nodes + // simply leave the subtitle line blank. height: NodeGeometry.headerHeight, padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm), decoration: BoxDecoration( @@ -145,77 +168,105 @@ class FlowNode extends StatelessWidget { topRight: Radius.circular(FaiRadius.md), ), ), - child: Row( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(_iconFor(kind), size: 16, color: accent), - const SizedBox(width: FaiSpace.xs), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.w600, - color: accent, - ), + // Title line — icon + step id + optional status dot. + SizedBox( + height: NodeGeometry.titleLineHeight, + child: Row( + children: [ + Icon(_iconFor(kind), size: 16, color: accent), + const SizedBox(width: FaiSpace.xs), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + color: accent, + ), + ), + ), + if (status != FlowNodeStatus.idle) _statusDot(theme), + ], ), ), - if (status != FlowNodeStatus.idle) _statusDot(theme), + // Subtitle line — always reserved space, even for + // endpoint nodes that have nothing to put here. + SizedBox( + height: NodeGeometry.subtitleLineHeight, + child: subtitle == null + ? const SizedBox.shrink() + : Padding( + // Indent to align with title text after the icon. + padding: const EdgeInsets.only(left: 20), + child: Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ), + ), ], ), ); } Widget _body(ThemeData theme) { + if (inputPortLabels.isEmpty) { + return const SizedBox.shrink(); + } return Padding( - padding: const EdgeInsets.fromLTRB( - FaiSpace.sm, - 4, - FaiSpace.sm, - FaiSpace.sm, + padding: const EdgeInsets.only( + top: NodeGeometry.bodyTopPad, + bottom: NodeGeometry.bodyBottomPad, ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - subtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ), for (final label in inputPortLabels) SizedBox( height: NodeGeometry.portRowHeight, - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: FaiSpace.xs), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm), + child: Row( + children: [ + // Inline port dot — coloured the same as + // the canvas-side dot so the operator can + // see exactly which row a connection lands + // on. Same vertical centre as the canvas- + // side dot because both are vertically + // centred in this 22-px row. + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _portColorFor(label, theme), + shape: BoxShape.circle, ), ), - ), - ], + const SizedBox(width: FaiSpace.xs), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), ), ), ], @@ -223,6 +274,38 @@ class FlowNode extends StatelessWidget { ); } + /// Colour for the inline port dot. For now we only have + /// type info for the inputs endpoint (labels carry + /// `name: type` like `doc: bytes`); step inputs don't + /// declare types in the YAML and we'd need module + /// manifests to colour them properly. Until that lands, + /// step input ports fall back to neutral and only the + /// inputs sidebar surfaces type colours. + Color _portColorFor(String label, ThemeData theme) { + final colon = label.indexOf(':'); + if (colon < 0) return theme.colorScheme.onSurfaceVariant; + final type = label.substring(colon + 1).trim(); + return _typeAccent(type, theme); + } + + Color _typeAccent(String type, ThemeData theme) { + final cs = theme.colorScheme; + switch (type) { + case 'text': + return cs.primary; + case 'bytes': + case 'file': + return cs.tertiary; + case 'json': + return cs.secondary; + case 'number': + case 'integer': + return Colors.amber.shade400; + default: + return cs.onSurfaceVariant; + } + } + Widget _statusDot(ThemeData theme) { final color = switch (status) { FlowNodeStatus.running => theme.colorScheme.primary, diff --git a/lib/src/widgets/properties_panel.dart b/lib/src/widgets/properties_panel.dart index d3092ce..4dff7f2 100644 --- a/lib/src/widgets/properties_panel.dart +++ b/lib/src/widgets/properties_panel.dart @@ -16,6 +16,7 @@ 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'; @@ -95,6 +96,24 @@ class _PropertiesPanelState extends State { final controller = widget.controller; 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, @@ -402,3 +421,526 @@ class _PropertiesPanelState extends State { return true; } } + +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(), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1420da1..da58708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.3.0 +version: 0.4.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor