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 nodes; final List 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 createState() => _NodeGraphCanvasState(); } class _NodeGraphCanvasState extends State { 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 nodesById; final List 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 = [ 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; }