Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
@@ -29,11 +29,7 @@ class AppStatCard extends StatelessWidget {
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 72,
|
||||
color: accentColor,
|
||||
),
|
||||
Container(width: 4, height: 72, color: accentColor),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -48,17 +44,18 @@ class AppStatCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
title.toUpperCase(),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
value,
|
||||
style: GoogleFonts.inter(
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -67,12 +64,7 @@ class AppStatCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: accentColor,
|
||||
),
|
||||
if (icon != null) Icon(icon, size: 20, color: accentColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
405
lib/presentation/common/widgets/node_graph_canvas.dart
Normal file
405
lib/presentation/common/widgets/node_graph_canvas.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user