import 'package:flutter/material.dart'; import '../../domain/bracket.dart'; import 'round_column.dart'; /// Renders a single-elimination bracket as a horizontal scrolling tree. /// /// Geometry rules: /// * Each round is a [RoundColumn] of width [_columnWidth] (220px card area /// + 20px right gap = 240px). /// * Round 1 matches are evenly distributed across the available height. /// * Each subsequent round's match N is centered between matches 2N and /// 2N+1 of the previous round. /// * Connector lines are drawn behind the cards by [_ConnectorsPainter]: /// a horizontal stub leaves each match's right edge, then a vertical /// segment joins to the horizontal stub entering the next round's match. class BracketTreeWidget extends StatelessWidget { const BracketTreeWidget({super.key, required this.bracket}); final Bracket bracket; // Layout constants. static const double _cardWidth = 200; static const double _cardHeight = 80; static const double _columnGap = 40; static const double _columnWidth = _cardWidth + _columnGap; // 240 static const double _matchSlotHeight = 120; // card + status line + spacing static const double _verticalPadding = 24; static const double _labelHeight = RoundColumn.labelHeight; @override Widget build(BuildContext context) { final rounds = bracket.rounds; if (rounds.isEmpty) { return Center( child: Text( 'This bracket has no rounds yet.', style: Theme.of(context).textTheme.bodyMedium, ), ); } final firstRoundMatches = rounds.first.matches.length.clamp(1, 1024); // Total drawable height inside the column body (below the round label). final bodyHeight = _matchSlotHeight * firstRoundMatches; final columnHeight = bodyHeight + _labelHeight + _verticalPadding * 2; final totalWidth = rounds.length * _columnWidth; // Compute card centers per round, in local column-body coordinates // (i.e. y measured from the top of the Stack that holds the cards). final centersByRound = >[]; for (var r = 0; r < rounds.length; r++) { final matchCount = rounds[r].matches.length; if (r == 0) { // Evenly distribute round 1. final slot = bodyHeight / matchCount; centersByRound.add([ for (var i = 0; i < matchCount; i++) _verticalPadding + slot * (i + 0.5), ]); } else { // Each match centered between its two feeders from previous round. final prev = centersByRound[r - 1]; final centers = []; for (var i = 0; i < matchCount; i++) { final a = i * 2; final b = a + 1; if (b < prev.length) { centers.add((prev[a] + prev[b]) / 2); } else if (a < prev.length) { centers.add(prev[a]); } else { centers.add(_verticalPadding + bodyHeight / 2); } } centersByRound.add(centers); } } final connectorColor = Theme.of(context).colorScheme.outlineVariant; return SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), child: SizedBox( width: totalWidth, height: columnHeight, child: Stack( children: [ // Connector lines drawn first so they sit behind the cards. Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _ConnectorsPainter( rounds: rounds, centersByRound: centersByRound, columnWidth: _columnWidth, cardWidth: _cardWidth, labelHeight: _labelHeight, color: connectorColor, ), ), ), ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var r = 0; r < rounds.length; r++) RoundColumn( round: rounds[r], cardCenters: centersByRound[r], columnWidth: _columnWidth, cardWidth: _cardWidth, cardHeight: _cardHeight, columnHeight: columnHeight, ), ], ), ], ), ), ); } } class _ConnectorsPainter extends CustomPainter { _ConnectorsPainter({ required this.rounds, required this.centersByRound, required this.columnWidth, required this.cardWidth, required this.labelHeight, required this.color, }); final List rounds; final List> centersByRound; final double columnWidth; final double cardWidth; final double labelHeight; final Color color; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = 1.5 ..style = PaintingStyle.stroke; // For each pair of adjacent rounds, draw connectors from every match in // the earlier round into its corresponding match in the later round. for (var r = 0; r < rounds.length - 1; r++) { final left = centersByRound[r]; final right = centersByRound[r + 1]; // Card horizontal bounds for this column. final colLeftX = r * columnWidth; final cardRightX = colLeftX + (columnWidth + cardWidth) / 2; final nextColLeftX = (r + 1) * columnWidth; final nextCardLeftX = nextColLeftX + (columnWidth - cardWidth) / 2; final midX = (cardRightX + nextCardLeftX) / 2; for (var i = 0; i < left.length; i++) { // Pair index in next round. final next = i ~/ 2; if (next >= right.length) continue; final fromY = left[i] + labelHeight; final toY = right[next] + labelHeight; // Right stub from card. canvas.drawLine(Offset(cardRightX, fromY), Offset(midX, fromY), paint); // Vertical segment connecting siblings. canvas.drawLine(Offset(midX, fromY), Offset(midX, toY), paint); // Left stub into next round's card. canvas.drawLine( Offset(midX, toY), Offset(nextCardLeftX, toY), paint, ); } } } @override bool shouldRepaint(covariant _ConnectorsPainter old) { return old.rounds != rounds || old.centersByRound != centersByRound || old.color != color || old.columnWidth != columnWidth || old.cardWidth != cardWidth || old.labelHeight != labelHeight; } }