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,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../events/application/events_notifier.dart';
|
||||
import '../application/brackets_notifier.dart';
|
||||
import 'widgets/bracket_tree_widget.dart';
|
||||
|
||||
/// Full-screen view of a single bracket. Hosts the [BracketTreeWidget] in the
|
||||
/// body and shows the parent event's title in the AppBar subtitle when
|
||||
/// available.
|
||||
class BracketDetailScreen extends ConsumerWidget {
|
||||
const BracketDetailScreen({super.key, required this.bracketId});
|
||||
|
||||
final String bracketId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bracket = ref.watch(bracketByIdProvider(bracketId));
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
if (bracket == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bracket'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/brackets'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('Bracket not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
final event = ref.watch(eventByIdProvider(bracket.eventId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/brackets'),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
bracket.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (event != null)
|
||||
Text(
|
||||
event.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
tooltip: 'Share bracket',
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sharing brackets is coming soon.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: BracketTreeWidget(bracket: bracket),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
size: 18,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Scroll horizontally to see all rounds',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../domain/bracket.dart';
|
||||
import '../infrastructure/brackets_repository.dart';
|
||||
import 'widgets/bracket_tree_widget.dart';
|
||||
|
||||
/// Top-level Brackets tab.
|
||||
///
|
||||
/// Routing behavior:
|
||||
/// * No brackets → empty state.
|
||||
/// * Exactly one bracket → render its tree inline (the common case for the
|
||||
/// MVP, where each event has a single main draw).
|
||||
/// * Multiple brackets → list view, tap to drill into `/brackets/:id`.
|
||||
class BracketsScreen extends ConsumerWidget {
|
||||
const BracketsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bracketsAsync = ref.watch(bracketsStreamProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Brackets')),
|
||||
body: bracketsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => _ErrorState(
|
||||
message: error.toString(),
|
||||
onRetry: () => ref.invalidate(bracketsStreamProvider),
|
||||
),
|
||||
data: (brackets) {
|
||||
if (brackets.isEmpty) {
|
||||
return const _EmptyState();
|
||||
}
|
||||
if (brackets.length == 1) {
|
||||
return _SingleBracketView(bracket: brackets.first);
|
||||
}
|
||||
return _BracketsList(brackets: brackets);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBracketView extends StatelessWidget {
|
||||
const _SingleBracketView({required this.bracket});
|
||||
|
||||
final Bracket bracket;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
bracket.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(child: BracketTreeWidget(bracket: bracket)),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
size: 18,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Scroll horizontally to see all rounds',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BracketsList extends StatelessWidget {
|
||||
const _BracketsList({required this.brackets});
|
||||
|
||||
final List<Bracket> brackets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final dateFormat = DateFormat('MMM d, y');
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: brackets.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final bracket = brackets[index];
|
||||
final totalMatches =
|
||||
bracket.rounds.fold<int>(0, (sum, r) => sum + r.matches.length);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/brackets/${bracket.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
child: const Icon(Icons.emoji_events),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
bracket.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${bracket.rounds.length} rounds · '
|
||||
'$totalMatches matches · '
|
||||
'Created ${dateFormat.format(bracket.createdAt)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.emoji_events_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No brackets yet',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tournament brackets will appear here once an event reaches '
|
||||
'its draw stage.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
const _ErrorState({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 64, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not load brackets',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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