406 lines
13 KiB
Dart
406 lines
13 KiB
Dart
import 'dart:ui' show PointMode;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
|
|
|
/// A node placed on a [NodeGraphCanvas].
|
|
class GraphNodeData {
|
|
const GraphNodeData({
|
|
required this.id,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.accent = AppColors.accent,
|
|
required this.position,
|
|
});
|
|
|
|
final String id;
|
|
final String title;
|
|
final String? subtitle;
|
|
final Color accent;
|
|
final Offset position;
|
|
}
|
|
|
|
/// A directed edge between two nodes.
|
|
class GraphEdgeData {
|
|
const GraphEdgeData(this.fromId, this.toId);
|
|
|
|
final String fromId;
|
|
final String toId;
|
|
}
|
|
|
|
/// A pannable / zoomable node-graph editor in the dojang style.
|
|
///
|
|
/// Interactions:
|
|
/// - drag empty space to pan, pinch / scroll to zoom
|
|
/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once)
|
|
/// - tap the link button on a node, then tap another node to connect them
|
|
/// ([onConnect]); connecting an already-linked pair is the caller's chance
|
|
/// to toggle the edge off
|
|
/// - long-press a node for the caller's context action ([onNodeLongPress])
|
|
class NodeGraphCanvas extends StatefulWidget {
|
|
const NodeGraphCanvas({
|
|
super.key,
|
|
required this.nodes,
|
|
required this.edges,
|
|
this.onNodeMoved,
|
|
this.onNodeDragEnd,
|
|
this.onConnect,
|
|
this.onNodeTap,
|
|
this.onNodeLongPress,
|
|
this.canvasSize = const Size(3000, 2000),
|
|
});
|
|
|
|
final List<GraphNodeData> nodes;
|
|
final List<GraphEdgeData> edges;
|
|
final void Function(String id, Offset position)? onNodeMoved;
|
|
final void Function(String id)? onNodeDragEnd;
|
|
final void Function(String fromId, String toId)? onConnect;
|
|
final void Function(String id)? onNodeTap;
|
|
final void Function(String id)? onNodeLongPress;
|
|
final Size canvasSize;
|
|
|
|
static const double nodeWidth = 172;
|
|
static const double nodeHeight = 56;
|
|
|
|
@override
|
|
State<NodeGraphCanvas> createState() => _NodeGraphCanvasState();
|
|
}
|
|
|
|
class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
|
final _transform = TransformationController();
|
|
String? _connectingFrom;
|
|
|
|
/// Node currently being dragged. While set, the InteractiveViewer's own
|
|
/// pan/zoom is disabled so the two gestures never fight for the pointer —
|
|
/// this is what makes node dragging feel 1:1 with the mouse.
|
|
String? _draggingId;
|
|
|
|
@override
|
|
void dispose() {
|
|
_transform.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
double get _scale => _transform.value.getMaxScaleOnAxis();
|
|
|
|
void _handleNodeBodyTap(String id) {
|
|
final from = _connectingFrom;
|
|
if (from != null) {
|
|
setState(() => _connectingFrom = null);
|
|
if (from != id) widget.onConnect?.call(from, id);
|
|
return;
|
|
}
|
|
widget.onNodeTap?.call(id);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final nodesById = {for (final n in widget.nodes) n.id: n};
|
|
|
|
return Stack(
|
|
children: [
|
|
InteractiveViewer(
|
|
transformationController: _transform,
|
|
constrained: false,
|
|
minScale: 0.35,
|
|
maxScale: 2.0,
|
|
panEnabled: _draggingId == null,
|
|
scaleEnabled: _draggingId == null,
|
|
boundaryMargin: const EdgeInsets.all(600),
|
|
child: GestureDetector(
|
|
// Tap on empty canvas cancels a pending connection.
|
|
onTap: () => setState(() => _connectingFrom = null),
|
|
child: SizedBox(
|
|
width: widget.canvasSize.width,
|
|
height: widget.canvasSize.height,
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: CustomPaint(
|
|
painter: _GraphPainter(
|
|
nodesById: nodesById,
|
|
edges: widget.edges,
|
|
),
|
|
),
|
|
),
|
|
for (final node in widget.nodes)
|
|
Positioned(
|
|
left: node.position.dx,
|
|
top: node.position.dy,
|
|
// Raw pointer events instead of a pan GestureDetector:
|
|
// no gesture-arena delay, no drag slop — the node
|
|
// follows the cursor from the first pixel.
|
|
child: Listener(
|
|
onPointerDown: widget.onNodeMoved == null
|
|
? null
|
|
: (_) => setState(() => _draggingId = node.id),
|
|
onPointerMove: widget.onNodeMoved == null
|
|
? null
|
|
: (event) {
|
|
if (_draggingId != node.id) return;
|
|
widget.onNodeMoved!(
|
|
node.id,
|
|
node.position + event.delta / _scale,
|
|
);
|
|
},
|
|
onPointerUp: (_) {
|
|
if (_draggingId == node.id) {
|
|
setState(() => _draggingId = null);
|
|
widget.onNodeDragEnd?.call(node.id);
|
|
}
|
|
},
|
|
onPointerCancel: (_) {
|
|
if (_draggingId == node.id) {
|
|
setState(() => _draggingId = null);
|
|
}
|
|
},
|
|
child: _NodeCard(
|
|
node: node,
|
|
isConnectSource: _connectingFrom == node.id,
|
|
connectMode: _connectingFrom != null,
|
|
showLinkButton: widget.onConnect != null,
|
|
onTap: () => _handleNodeBodyTap(node.id),
|
|
onLongPress: widget.onNodeLongPress == null
|
|
? null
|
|
: () => widget.onNodeLongPress!(node.id),
|
|
onStartConnect: () => setState(() {
|
|
_connectingFrom = _connectingFrom == node.id
|
|
? null
|
|
: node.id;
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_connectingFrom != null)
|
|
Positioned(
|
|
top: 12,
|
|
left: 0,
|
|
right: 0,
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 7,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.accentMuted,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: AppColors.accentBorder),
|
|
),
|
|
child: Text(
|
|
'TAP A NODE TO CONNECT · TAP CANVAS TO CANCEL',
|
|
style: GoogleFonts.jetBrainsMono(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 1,
|
|
color: AppColors.accent,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _NodeCard extends StatelessWidget {
|
|
const _NodeCard({
|
|
required this.node,
|
|
required this.isConnectSource,
|
|
required this.connectMode,
|
|
required this.showLinkButton,
|
|
required this.onTap,
|
|
this.onLongPress,
|
|
required this.onStartConnect,
|
|
});
|
|
|
|
final GraphNodeData node;
|
|
final bool isConnectSource;
|
|
final bool connectMode;
|
|
final bool showLinkButton;
|
|
final VoidCallback onTap;
|
|
final VoidCallback? onLongPress;
|
|
final VoidCallback onStartConnect;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final borderColor = isConnectSource
|
|
? node.accent
|
|
: connectMode
|
|
? node.accent.withValues(alpha: 0.4)
|
|
: AppColors.border;
|
|
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
child: Container(
|
|
width: NodeGraphCanvas.nodeWidth,
|
|
height: NodeGraphCanvas.nodeHeight,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceContainer,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: borderColor,
|
|
width: isConnectSource ? 2 : 1,
|
|
),
|
|
boxShadow: isConnectSource
|
|
? [
|
|
BoxShadow(
|
|
color: node.accent.withValues(alpha: 0.35),
|
|
blurRadius: 16,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: node.accent,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
node.title.toUpperCase(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: GoogleFonts.chakraPetch(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.5,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
if (node.subtitle != null && node.subtitle!.isNotEmpty)
|
|
Text(
|
|
node.subtitle!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: GoogleFonts.jetBrainsMono(
|
|
fontSize: 9,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (showLinkButton)
|
|
GestureDetector(
|
|
onTap: onStartConnect,
|
|
child: Container(
|
|
width: 26,
|
|
height: double.infinity,
|
|
color: Colors.transparent,
|
|
child: Icon(
|
|
Icons.trending_flat_rounded,
|
|
size: 16,
|
|
color: isConnectSource ? node.accent : AppColors.textMuted,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edges + dot grid painter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _GraphPainter extends CustomPainter {
|
|
_GraphPainter({required this.nodesById, required this.edges});
|
|
|
|
final Map<String, GraphNodeData> nodesById;
|
|
final List<GraphEdgeData> edges;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
_paintDotGrid(canvas, size);
|
|
|
|
final edgePaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2;
|
|
|
|
for (final edge in edges) {
|
|
final from = nodesById[edge.fromId];
|
|
final to = nodesById[edge.toId];
|
|
if (from == null || to == null) continue;
|
|
|
|
final start =
|
|
from.position +
|
|
const Offset(
|
|
NodeGraphCanvas.nodeWidth,
|
|
NodeGraphCanvas.nodeHeight / 2,
|
|
);
|
|
final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2);
|
|
|
|
edgePaint.color = from.accent.withValues(alpha: 0.75);
|
|
|
|
final dx = ((end.dx - start.dx).abs() * 0.5).clamp(40.0, 160.0);
|
|
final path = Path()
|
|
..moveTo(start.dx, start.dy)
|
|
..cubicTo(
|
|
start.dx + dx,
|
|
start.dy,
|
|
end.dx - dx,
|
|
end.dy,
|
|
end.dx - 7,
|
|
end.dy,
|
|
);
|
|
canvas.drawPath(path, edgePaint);
|
|
|
|
// Arrowhead
|
|
final arrow = Path()
|
|
..moveTo(end.dx, end.dy)
|
|
..lineTo(end.dx - 9, end.dy - 5)
|
|
..lineTo(end.dx - 9, end.dy + 5)
|
|
..close();
|
|
canvas.drawPath(
|
|
arrow,
|
|
Paint()..color = from.accent.withValues(alpha: 0.9),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _paintDotGrid(Canvas canvas, Size size) {
|
|
const step = 28.0;
|
|
final points = <Offset>[
|
|
for (double x = step; x < size.width; x += step)
|
|
for (double y = step; y < size.height; y += step) Offset(x, y),
|
|
];
|
|
canvas.drawPoints(
|
|
PointMode.points,
|
|
points,
|
|
Paint()
|
|
..color = AppColors.surfaceContainerHigh
|
|
..strokeWidth = 1.6
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_GraphPainter oldDelegate) => true;
|
|
}
|