Initial commit: Flutter app + PHP/MySQL backend on Hostinger
Replaces Firebase with a self-hosted PHP/MySQL API served from winded.prymsolutions.com. Includes full backend (schema, auth, events, teams, brackets, suggestions, stats, media, file upload) and updated Flutter repositories and domain models. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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 = <List<double>>[];
|
||||
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 = <double>[];
|
||||
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<BracketRound> rounds;
|
||||
final List<List<double>> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../domain/bracket.dart';
|
||||
|
||||
/// Compact card representing a single [BracketMatch] inside the bracket tree.
|
||||
///
|
||||
/// Fixed 200x80 footprint so the bracket tree can lay matches out with
|
||||
/// predictable geometry. If the match is scheduled, an additional date line
|
||||
/// is rendered directly beneath the card.
|
||||
class MatchCard extends StatelessWidget {
|
||||
const MatchCard({
|
||||
super.key,
|
||||
required this.match,
|
||||
this.width = 200,
|
||||
this.height = 80,
|
||||
});
|
||||
|
||||
final BracketMatch match;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamRow(
|
||||
team: match.teamA,
|
||||
score: match.scoreA,
|
||||
isWinner: match.isTeamAWinner,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: scheme.outlineVariant),
|
||||
Expanded(
|
||||
child: _TeamRow(
|
||||
team: match.teamB,
|
||||
score: match.scoreB,
|
||||
isWinner: match.isTeamBWinner,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_StatusLine(match: match, width: width),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamRow extends StatelessWidget {
|
||||
const _TeamRow({
|
||||
required this.team,
|
||||
required this.score,
|
||||
required this.isWinner,
|
||||
});
|
||||
|
||||
final BracketTeam? team;
|
||||
final int? score;
|
||||
final bool isWinner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final name = team?.name ?? 'TBD';
|
||||
final nameStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isWinner ? FontWeight.w700 : FontWeight.w500,
|
||||
color: team == null
|
||||
? scheme.onSurfaceVariant
|
||||
: (isWinner ? scheme.onPrimaryContainer : scheme.onSurface),
|
||||
fontStyle: team == null ? FontStyle.italic : FontStyle.normal,
|
||||
);
|
||||
final scoreStyle = theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: isWinner ? FontWeight.w800 : FontWeight.w600,
|
||||
color: isWinner ? scheme.onPrimaryContainer : scheme.onSurface,
|
||||
);
|
||||
|
||||
return Container(
|
||||
color: isWinner ? scheme.primaryContainer : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: nameStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
score?.toString() ?? '',
|
||||
textAlign: TextAlign.right,
|
||||
style: scoreStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.match, required this.width});
|
||||
|
||||
final BracketMatch match;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final (Color dot, String label) = switch (match.status) {
|
||||
MatchStatus.completed => (Colors.green.shade600, 'Final'),
|
||||
MatchStatus.inProgress => (Colors.amber.shade700, 'Live'),
|
||||
MatchStatus.scheduled => (scheme.outline, _scheduledLabel(match)),
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _scheduledLabel(BracketMatch match) {
|
||||
final scheduled = match.scheduledAt;
|
||||
if (scheduled == null) return 'Scheduled';
|
||||
return DateFormat('MMM d · h:mm a').format(scheduled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/bracket.dart';
|
||||
import 'match_card.dart';
|
||||
|
||||
/// A single column in the bracket tree: a round label at the top, then a
|
||||
/// vertical stack of [MatchCard]s positioned according to the bracket
|
||||
/// geometry computed by [BracketTreeWidget].
|
||||
///
|
||||
/// The widget itself does not compute spacing — its parent supplies a
|
||||
/// per-card vertical offset so all rounds align even when match counts
|
||||
/// differ between columns.
|
||||
class RoundColumn extends StatelessWidget {
|
||||
const RoundColumn({
|
||||
super.key,
|
||||
required this.round,
|
||||
required this.cardCenters,
|
||||
required this.columnWidth,
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
required this.columnHeight,
|
||||
});
|
||||
|
||||
final BracketRound round;
|
||||
|
||||
/// Vertical center y-coordinate (in this column's local space) for each
|
||||
/// match card. Same length as `round.matches`.
|
||||
final List<double> cardCenters;
|
||||
|
||||
final double columnWidth;
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
final double columnHeight;
|
||||
|
||||
static const double labelHeight = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: columnWidth,
|
||||
height: columnHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: labelHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
round.label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = 0; i < round.matches.length; i++)
|
||||
Positioned(
|
||||
left: (columnWidth - cardWidth) / 2,
|
||||
top: cardCenters[i] - cardHeight / 2,
|
||||
width: cardWidth,
|
||||
child: MatchCard(
|
||||
match: round.matches[i],
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user