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

@ -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(),
),
],
),
);
}
}