Skip to content

Commit 72ae0ee

Browse files
committed
Progress in connector widget
1 parent 58dc958 commit 72ae0ee

5 files changed

Lines changed: 339 additions & 5 deletions

File tree

flutter/ayf_connect/lib/editor/fl_nodes_adapter.dart

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,21 @@ import 'node_type_definition.dart';
2222
/// ein aktuelles, framework-unabhängiges Abbild des Diagramms liefert.
2323
class FlNodesAdapter {
2424
final DiagramController diagramController;
25-
final List<NodeTypeDefinition> nodeTypes;
25+
final List<NodeTypeDefinition> _nodeTypes;
26+
27+
/// The node types currently registered with [flController], i.e. the ones
28+
/// [loadDiagram] will accept. Grows over time via [registerNodeTypes] -
29+
/// see that method for why the constructor's [nodeTypes] alone isn't
30+
/// always enough.
31+
List<NodeTypeDefinition> get nodeTypes => List.unmodifiable(_nodeTypes);
2632

2733
late final FlNodeEditorController flController;
2834
StreamSubscription<dynamic>? _subscription;
2935

3036
FlNodesAdapter({
3137
required this.diagramController,
32-
required this.nodeTypes,
33-
}) {
38+
required List<NodeTypeDefinition> nodeTypes,
39+
}) : _nodeTypes = List.of(nodeTypes) {
3440
// autoBuildGraph/autoRunGraph gehören zur visuellen-Programmierung/
3541
// Execution-Funktionalität von fl_nodes, die wir hier nicht nutzen
3642
// (unsere Nodes definieren nur Datenfluss, kein onExecute). Ohne diese
@@ -43,13 +49,37 @@ class FlNodesAdapter {
4349
),
4450
);
4551

46-
for (final nodeType in nodeTypes) {
52+
for (final nodeType in _nodeTypes) {
4753
flController.registerNodePrototype(_buildPrototype(nodeType));
4854
}
4955

5056
_subscription = flController.eventBus.events.listen(_handleEvent);
5157
}
5258

59+
/// Registers every type in [types] that isn't already known (matched by
60+
/// [NodeTypeDefinition.typeId]) with both [flController] and [nodeTypes];
61+
/// existing types are left untouched, so this is safe (and cheap) to call
62+
/// repeatedly with the same or a growing catalog.
63+
///
64+
/// Needed for callers that derive node types from live, per-instance data
65+
/// rather than a fixed catalog known up front - e.g.
66+
/// AudYoFloConnectFlowWidget in the startern app builds one type per
67+
/// distinct input/output port *count* (a backend component's number of
68+
/// connectors isn't known statically), and a later refresh of the backend
69+
/// cache can well introduce a shape (say, three output branches) that no
70+
/// earlier [loadDiagram] call has seen yet. Without this, [loadDiagram]
71+
/// would reject such a diagram: it only validates against types registered
72+
/// at construction time.
73+
void registerNodeTypes(List<NodeTypeDefinition> types) {
74+
for (final type in types) {
75+
if (_nodeTypes.any((existing) => existing.typeId == type.typeId)) {
76+
continue;
77+
}
78+
_nodeTypes.add(type);
79+
flController.registerNodePrototype(_buildPrototype(type));
80+
}
81+
}
82+
5383
void dispose() {
5484
_subscription?.cancel();
5585
flController.dispose();

flutter/ayf_connect/lib/widgets/canvas_page.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ class _CanvasPageState extends State<CanvasPage> {
6969
@override
7070
void didUpdateWidget(covariant CanvasPage oldWidget) {
7171
super.didUpdateWidget(oldWidget);
72+
if (!identical(widget.nodeTypes, oldWidget.nodeTypes)) {
73+
// Cheap/idempotent (see FlNodesAdapter.registerNodeTypes) - covers
74+
// callers whose catalog is derived from live data and can grow
75+
// between builds (e.g. AudYoFloConnectFlowWidget adding a node-type
76+
// shape for a port count not seen before), which initState's one-time
77+
// registration from the first widget.nodeTypes wouldn't pick up.
78+
_adapter.registerNodeTypes(widget.nodeTypes);
79+
}
7280
final newDiagram = widget.diagram;
7381
if (newDiagram != null && !identical(newDiagram, oldWidget.diagram)) {
7482
_scheduleLoadDiagram(newDiagram);
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import 'package:fl_nodes/fl_nodes.dart';
2+
import 'package:flutter/material.dart';
3+
// ignore: depend_on_referenced_packages
4+
import 'package:flutter_localizations/flutter_localizations.dart';
5+
import 'package:flutter_test/flutter_test.dart';
6+
7+
import 'package:connectorflo/editor/fl_nodes_adapter.dart';
8+
import 'package:connectorflo/editor/node_type_definition.dart';
9+
import 'package:connectorflo/models/port_type.dart';
10+
import 'package:connectorflo/state/diagram_controller.dart';
11+
12+
// Regressionstests für [FlNodesAdapter.arrangeRow] und
13+
// [FlNodesAdapter.nextFreeSlot]: beide sollen Nodes so platzieren, dass sie
14+
// sich nie überlappen - unabhängig davon, wie stark ihre (labelabhängige)
15+
// gerenderte Größe variiert, und unabhängig von fl_nodes' Snap-to-Grid
16+
// (FlNodeEditorConfig.enableSnapToGrid, standardmäßig an, 64px-Raster), das
17+
// eine naiv berechnete Zielposition durch eigenes Runden wieder zunichte
18+
// machen kann. Diese Tests pumpen echte Widgets (statt nur Positionen zu
19+
// vergleichen), damit tatsächlich gerenderte RenderBox-Größen einfließen -
20+
// genau die Größe, die im echten Canvas auch für Überlappungen sorgt.
21+
void main() {
22+
// Bewusst stark unterschiedliche Label-Längen/Port-Zahlen, damit die
23+
// gerenderte Breite/Höhe der Nodes deutlich variiert.
24+
const nodeTypes = [
25+
NodeTypeDefinition(typeId: 'short', label: 'A'),
26+
NodeTypeDefinition(
27+
typeId: 'long',
28+
label: 'A Very Long Node Label That Forces A Wide Box',
29+
),
30+
NodeTypeDefinition(
31+
typeId: 'medium',
32+
label: 'Medium Label Node',
33+
inputs: [
34+
PortSpec(id: 'in1', label: 'Input One', type: PortType.string),
35+
],
36+
outputs: [
37+
PortSpec(id: 'out1', label: 'Output One', type: PortType.string),
38+
],
39+
),
40+
];
41+
42+
Future<FlNodesAdapter> pumpEditor(WidgetTester tester) async {
43+
final diagramController = DiagramController();
44+
final adapter = FlNodesAdapter(
45+
diagramController: diagramController,
46+
nodeTypes: nodeTypes,
47+
);
48+
addTearDown(adapter.dispose);
49+
50+
await tester.pumpWidget(
51+
MaterialApp(
52+
localizationsDelegates: const [
53+
FlNodeEditorLocalizationsDelegate(),
54+
GlobalMaterialLocalizations.delegate,
55+
GlobalWidgetsLocalizations.delegate,
56+
GlobalCupertinoLocalizations.delegate,
57+
],
58+
supportedLocales: const [Locale('de'), Locale('en')],
59+
home: Scaffold(
60+
body: SizedBox(
61+
width: 1200,
62+
height: 800,
63+
child: FlNodeEditorWidget(
64+
controller: adapter.flController,
65+
expandToParent: true,
66+
overlay: () => [],
67+
),
68+
),
69+
),
70+
),
71+
);
72+
await tester.pumpAndSettle();
73+
return adapter;
74+
}
75+
76+
Rect boundsOf(FlNodesAdapter adapter, String id) {
77+
final node = adapter.flController.getNodeById(id)!;
78+
final renderObject =
79+
node.key.currentContext!.findRenderObject()! as RenderBox;
80+
return node.offset & renderObject.size;
81+
}
82+
83+
void expectNoOverlaps(List<Rect> rects) {
84+
for (var i = 0; i < rects.length; i++) {
85+
for (var j = i + 1; j < rects.length; j++) {
86+
expect(
87+
rects[i].overlaps(rects[j]),
88+
isFalse,
89+
reason: 'Node $i (${rects[i]}) überlappt mit Node $j (${rects[j]})',
90+
);
91+
}
92+
}
93+
}
94+
95+
testWidgets(
96+
'arrangeRow ordnet unterschiedlich große Nodes ohne Überlappung an',
97+
(tester) async {
98+
final adapter = await pumpEditor(tester);
99+
100+
final ids = <String>[];
101+
for (final typeId in ['long', 'short', 'medium', 'long', 'short']) {
102+
ids.add(adapter.flController.addNode(typeId).id);
103+
}
104+
await tester.pumpAndSettle();
105+
106+
adapter.arrangeRow();
107+
await tester.pumpAndSettle();
108+
109+
final rects = [for (final id in ids) boundsOf(adapter, id)];
110+
expectNoOverlaps(rects);
111+
112+
// Nicht nur "irgendwie kein Overlap", sondern mindestens der
113+
// angeforderte Abstand zwischen aufeinanderfolgenden Nodes - sonst
114+
// würde z.B. ein Grid-Snap, der den Gap zufällig auf 0 rundet, den
115+
// Test unbemerkt durchrutschen lassen.
116+
final byLeft = [...rects]..sort((a, b) => a.left.compareTo(b.left));
117+
for (var i = 0; i < byLeft.length - 1; i++) {
118+
expect(byLeft[i + 1].left - byLeft[i].right, greaterThanOrEqualTo(48));
119+
}
120+
},
121+
);
122+
123+
testWidgets(
124+
'nextFreeSlot platziert neu hinzugefügte Nodes ohne Überlappung',
125+
(tester) async {
126+
final adapter = await pumpEditor(tester);
127+
128+
final ids = <String>[];
129+
for (final typeId in ['short', 'long', 'medium', 'long']) {
130+
final offset = adapter.nextFreeSlot();
131+
final node = adapter.flController.addNode(typeId, offset: offset);
132+
ids.add(node.id);
133+
// Jeder Palette-Klick im echten UI liegt hinter einem eigenen
134+
// Frame - genau das simuliert dieses pumpAndSettle je Iteration,
135+
// damit nextFreeSlot beim nächsten Aufruf die reale Größe des
136+
// gerade hinzugefügten Node sieht (statt fallbackSize).
137+
await tester.pumpAndSettle();
138+
}
139+
140+
final rects = [for (final id in ids) boundsOf(adapter, id)];
141+
expectNoOverlaps(rects);
142+
},
143+
);
144+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter/gestures.dart';
3+
import 'package:flutter/material.dart';
4+
// ignore: depend_on_referenced_packages
5+
import 'package:flutter_localizations/flutter_localizations.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:provider/provider.dart';
8+
9+
import 'package:connectorflo/state/diagram_controller.dart';
10+
import 'package:connectorflo/widgets/canvas_page.dart';
11+
import 'package:fl_nodes/fl_nodes.dart';
12+
13+
// fl_nodes' default node widget (default_node.dart) picks its gesture
14+
// implementation by `defaultTargetPlatform`: a touch-oriented GestureDetector
15+
// for Android/iOS, an ImprovedListener keyed to specific mouse buttons for
16+
// every other platform (including Windows, this app's primary target). Since
17+
// `flutter test` defaults `defaultTargetPlatform` to Android regardless of
18+
// the host OS, a test that doesn't override it exercises the touch branch -
19+
// not what a Windows mouse user actually gets. Every test in this file
20+
// forces the desktop branch so it reflects the real app.
21+
void main() {
22+
// TestWidgetsFlutterBinding checks that debug-only foundation variables
23+
// are back to their default before the test callback returns (not merely
24+
// before the surrounding tearDown() runs), so the override has to be
25+
// cleared inside the test body itself - see the try/finally in each test.
26+
Future<void> pumpEditPage(
27+
WidgetTester tester,
28+
DiagramController diagramController,
29+
) async {
30+
await tester.pumpWidget(
31+
MaterialApp(
32+
localizationsDelegates: const [
33+
FlNodeEditorLocalizationsDelegate(),
34+
GlobalMaterialLocalizations.delegate,
35+
GlobalWidgetsLocalizations.delegate,
36+
GlobalCupertinoLocalizations.delegate,
37+
],
38+
supportedLocales: const [Locale('de'), Locale('en')],
39+
home: ChangeNotifierProvider<DiagramController>.value(
40+
value: diagramController,
41+
child: const CanvasPage(),
42+
),
43+
),
44+
);
45+
await tester.pumpAndSettle();
46+
}
47+
48+
testWidgets(
49+
'Node im Edit-Modus lässt sich per Maus-Drag (linke Taste) verschieben',
50+
(tester) async {
51+
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
52+
try {
53+
final diagramController = DiagramController();
54+
await pumpEditPage(tester, diagramController);
55+
56+
// Über die Palette hinzufügen (echter Klick, kein direkter
57+
// Controller-Aufruf).
58+
await tester.tap(find.text('Source').first);
59+
await tester.pumpAndSettle();
60+
expect(diagramController.nodes, hasLength(1));
61+
62+
final before = diagramController.nodes.single.position;
63+
64+
// fl_nodes paints nodes through a raw Canvas.transform (viewport
65+
// centering + zoom + pan) applied only at paint time, which
66+
// Flutter's own hit-test tree - and therefore
67+
// tester.getCenter()/getRect() on a node's own widgets - knows
68+
// nothing about (see NodeEditorRenderObject._getTransformMatrix vs.
69+
// the plain childParentData.offset = node.offset used for layout).
70+
// A screen point that actually lands on the node has to be
71+
// computed by re-deriving that same transform instead: newly
72+
// added nodes start at the default zoom (1.0) and viewport offset
73+
// (0,0), so the node's world offset maps to screen as
74+
// `editorRect.center + node.offset` (see fl_nodes' own
75+
// hitTestChildren, which computes the inverse of exactly this).
76+
final editorRect = tester.getRect(find.byType(FlNodeEditorWidget));
77+
// A small delta into the node's body (well clear of its single
78+
// output port on the right edge), not its exact center, so this
79+
// doesn't depend on knowing the node's rendered size.
80+
final start = editorRect.center + before + const Offset(30, 20);
81+
82+
final gesture = await tester.startGesture(
83+
start,
84+
kind: PointerDeviceKind.mouse,
85+
);
86+
await tester.pump(const Duration(milliseconds: 50));
87+
await gesture.moveBy(const Offset(150, 100));
88+
await tester.pump(const Duration(milliseconds: 50));
89+
await gesture.moveBy(const Offset(20, 20));
90+
await tester.pump(const Duration(milliseconds: 50));
91+
await gesture.up();
92+
await tester.pumpAndSettle();
93+
94+
final after = diagramController.nodes.single.position;
95+
expect(
96+
after,
97+
isNot(equals(before)),
98+
reason:
99+
'Node-Position vor Drag: $before, nach Drag: $after - hat '
100+
'sich nicht verändert.',
101+
);
102+
} finally {
103+
debugDefaultTargetPlatformOverride = null;
104+
}
105+
},
106+
);
107+
108+
testWidgets(
109+
'Canvas lässt sich per mittlerer Maustaste verschieben (fl_nodes-Pan-Geste)',
110+
(tester) async {
111+
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
112+
try {
113+
final diagramController = DiagramController();
114+
await pumpEditPage(tester, diagramController);
115+
116+
String? offsetText() {
117+
final texts = tester
118+
.widgetList<Text>(find.textContaining('Offset:'))
119+
.map((t) => t.data)
120+
.toList();
121+
return texts.isEmpty ? null : texts.first;
122+
}
123+
124+
final before = offsetText();
125+
126+
final center = tester.getCenter(find.byType(FlNodeEditorWidget));
127+
final gesture = await tester.startGesture(
128+
center,
129+
kind: PointerDeviceKind.mouse,
130+
buttons: kMiddleMouseButton,
131+
);
132+
await tester.pump(const Duration(milliseconds: 50));
133+
await gesture.moveBy(const Offset(120, 0));
134+
await tester.pump(const Duration(milliseconds: 50));
135+
await gesture.up();
136+
await tester.pumpAndSettle();
137+
138+
final after = offsetText();
139+
140+
expect(
141+
after,
142+
isNot(equals(before)),
143+
reason:
144+
'Debug-Overlay Offset vor Drag: $before, nach Drag: $after - '
145+
'hat sich nicht verändert.',
146+
);
147+
} finally {
148+
debugDefaultTargetPlatformOverride = null;
149+
}
150+
},
151+
);
152+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[],"android":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"ayf_pack","path":"T:\\\\AudYoFlo\\\\flutter\\\\ayf_pack\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"ayf_pack","path":"T:\\\\AudYoFlo\\\\flutter\\\\ayf_pack\\\\","dependencies":[],"dev_dependency":false},{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"ayf_pack","dependencies":[]},{"name":"desktop_drop","dependencies":[]},{"name":"flutter_window_close","dependencies":[]}],"date_created":"2026-08-20 21:30:50.997471","version":"3.29.3","swift_package_manager_enabled":{"ios":false,"macos":false}}
1+
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[],"android":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"ayf_pack","path":"T:\\\\AudYoFlo\\\\flutter\\\\ayf_pack\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"ayf_pack","path":"T:\\\\AudYoFlo\\\\flutter\\\\ayf_pack\\\\","dependencies":[],"dev_dependency":false},{"name":"desktop_drop","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\desktop_drop-0.4.4\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_window_close","path":"C:\\\\Users\\\\Hauke Krüger\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_window_close-1.3.0\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"ayf_pack","dependencies":[]},{"name":"desktop_drop","dependencies":[]},{"name":"flutter_window_close","dependencies":[]}],"date_created":"2026-08-21 20:30:02.456113","version":"3.29.3","swift_package_manager_enabled":{"ios":false,"macos":false}}

0 commit comments

Comments
 (0)