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:
parent
296e5bfd01
commit
fb8892687d
6 changed files with 761 additions and 81 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue