feat(editor): port geometry fix + type colours + endpoint editor + unsaved badge

Four operator-visible improvements in one cut.

Port geometry (the "connection dots don't line up with the
labels" bug):
 - Header now has a fixed two-line structure (title + 16-px
   subtitle slot) regardless of node kind, so the body's
   port-row Y-offsets are identical across step nodes and
   endpoint nodes.
 - NodeGeometry constants rewritten so the inline 8-px dot
   inside each port row and the 16-px canvas-side dot both
   compute to the same Y. Port rows now anchor where the
   eye expects them.

Port type colours:
 - Inputs endpoint labels carry `name: type` (e.g.
   `doc: bytes`), so the FlowNode body parses the type and
   tints the inline dot per type: text → primary,
   bytes/file → tertiary, json → secondary, number → amber,
   unknown → muted. Step input ports stay muted until a
   future commit can read module manifests for type info.

Endpoint editor:
 - Inputs and outputs nodes are now selectable. Selecting
   opens a dedicated editor in the properties panel:
   add / rename / retype / remove entries graphically
   instead of forcing the operator into the text tab.
   Inputs editor offers a Type dropdown
   (text / bytes / json / file / number);
   outputs editor exposes name + expression
   (e.g. `$step.field`).

Unsaved badge:
 - The toolbar's tiny 8-px dirty dot was easy to miss.
   Replace it with a coloured "unsaved" / "ungespeichert"
   chip + colour the file name itself in the primary accent
   when dirty. Operators see at a glance that a flow has
   unsaved changes, which matters because the Discard
   confirmation prompt won't fire if they don't realise
   they made changes.

Version 0.3.0 -> 0.4.0 — first new editor feature surface
since 0.3.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 16:31:28 +02:00
parent 296e5bfd01
commit fb8892687d
6 changed files with 761 additions and 81 deletions

View file

@ -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,

View file

@ -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');
}

View file

@ -343,6 +343,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
required NodeVisualKind kind,
required List<String> labels,
}) {
final selected = widget.controller.selectedStepId == nodeId;
return Positioned(
left: pos.x,
top: pos.y,
@ -351,7 +352,12 @@ class _FlowCanvasState extends State<FlowCanvas> {
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,

View file

@ -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,

View file

@ -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<PropertiesPanel> {
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<PropertiesPanel> {
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 = <String, FlowInput>{};
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<String>(
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 = <String, String>{};
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(),
),
],
),
);
}
}