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