Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s

This commit is contained in:
Kazimierz Ciołek
2026-07-06 23:44:17 +02:00
parent 9dcc4b87de
commit 6dd7213eb0
48 changed files with 3229 additions and 669 deletions

View 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;
}