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:
2026-05-14 20:13:57 -07:00
commit b239ae3e5f
208 changed files with 19187 additions and 0 deletions
@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/bracket.dart';
import '../infrastructure/brackets_repository.dart';
part 'brackets_notifier.g.dart';
/// Currently-selected bracket id used when navigating to the detail screen.
@riverpod
class SelectedBracketId extends _$SelectedBracketId {
@override
String? build() => null;
void select(String? id) => state = id;
}
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
@riverpod
Bracket? bracketById(BracketByIdRef ref, String id) {
final brackets = ref.watch(bracketsStreamProvider).valueOrNull;
if (brackets == null) return null;
for (final bracket in brackets) {
if (bracket.id == id) return bracket;
}
return null;
}
@@ -0,0 +1,183 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'brackets_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$bracketByIdHash() => r'c49c89b5fe87117266a8ca6c2c25009b0b290f60';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
@ProviderFor(bracketById)
const bracketByIdProvider = BracketByIdFamily();
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
class BracketByIdFamily extends Family<Bracket?> {
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
const BracketByIdFamily();
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
BracketByIdProvider call(String id) {
return BracketByIdProvider(id);
}
@override
BracketByIdProvider getProviderOverride(
covariant BracketByIdProvider provider,
) {
return call(provider.id);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'bracketByIdProvider';
}
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
class BracketByIdProvider extends AutoDisposeProvider<Bracket?> {
/// Resolves a single [Bracket] by id out of the brackets stream. Returns null
/// while loading or if no bracket matches.
///
/// Copied from [bracketById].
BracketByIdProvider(String id)
: this._internal(
(ref) => bracketById(ref as BracketByIdRef, id),
from: bracketByIdProvider,
name: r'bracketByIdProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bracketByIdHash,
dependencies: BracketByIdFamily._dependencies,
allTransitiveDependencies: BracketByIdFamily._allTransitiveDependencies,
id: id,
);
BracketByIdProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.id,
}) : super.internal();
final String id;
@override
Override overrideWith(Bracket? Function(BracketByIdRef provider) create) {
return ProviderOverride(
origin: this,
override: BracketByIdProvider._internal(
(ref) => create(ref as BracketByIdRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
id: id,
),
);
}
@override
AutoDisposeProviderElement<Bracket?> createElement() {
return _BracketByIdProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is BracketByIdProvider && other.id == id;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, id.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin BracketByIdRef on AutoDisposeProviderRef<Bracket?> {
/// The parameter `id` of this provider.
String get id;
}
class _BracketByIdProviderElement extends AutoDisposeProviderElement<Bracket?>
with BracketByIdRef {
_BracketByIdProviderElement(super.provider);
@override
String get id => (origin as BracketByIdProvider).id;
}
String _$selectedBracketIdHash() => r'1562a0b74ce4868ad5e49de98e5287551b7a423b';
/// Currently-selected bracket id used when navigating to the detail screen.
///
/// Copied from [SelectedBracketId].
@ProviderFor(SelectedBracketId)
final selectedBracketIdProvider =
AutoDisposeNotifierProvider<SelectedBracketId, String?>.internal(
SelectedBracketId.new,
name: r'selectedBracketIdProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$selectedBracketIdHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SelectedBracketId = AutoDisposeNotifier<String?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
+322
View File
@@ -0,0 +1,322 @@
/// Lifecycle state of a single bracket match.
enum MatchStatus { scheduled, inProgress, completed }
/// Lightweight team reference stored inline on a bracket match. The full team
/// record (roster, record, etc.) lives in the teams feature; brackets only
/// need an id, a display name, and an optional logo.
class BracketTeam {
const BracketTeam({
required this.id,
required this.name,
this.logoUrl,
});
final String id;
final String name;
final String? logoUrl;
BracketTeam copyWith({String? id, String? name, String? logoUrl}) {
return BracketTeam(
id: id ?? this.id,
name: name ?? this.name,
logoUrl: logoUrl ?? this.logoUrl,
);
}
factory BracketTeam.fromMap(Map<String, dynamic> data) {
return BracketTeam(
id: (data['id'] as String?) ?? '',
name: (data['name'] as String?) ?? '',
logoUrl: data['logo_url'] as String?,
);
}
Map<String, Object?> toMap() {
return <String, Object?>{
'id': id,
'name': name,
'logo_url': logoUrl,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BracketTeam &&
other.id == id &&
other.name == name &&
other.logoUrl == logoUrl;
}
@override
int get hashCode => Object.hash(id, name, logoUrl);
}
/// A single match within a bracket. Either team may be null while previous
/// rounds are still being decided (a `null` team renders as "TBD").
class BracketMatch {
const BracketMatch({
required this.id,
required this.status,
this.teamA,
this.teamB,
this.scoreA,
this.scoreB,
this.scheduledAt,
this.winnerId,
});
final String id;
final BracketTeam? teamA;
final BracketTeam? teamB;
final int? scoreA;
final int? scoreB;
final MatchStatus status;
final DateTime? scheduledAt;
final String? winnerId;
bool get isTeamAWinner =>
winnerId != null && teamA != null && winnerId == teamA!.id;
bool get isTeamBWinner =>
winnerId != null && teamB != null && winnerId == teamB!.id;
BracketMatch copyWith({
String? id,
BracketTeam? teamA,
BracketTeam? teamB,
int? scoreA,
int? scoreB,
MatchStatus? status,
DateTime? scheduledAt,
String? winnerId,
}) {
return BracketMatch(
id: id ?? this.id,
teamA: teamA ?? this.teamA,
teamB: teamB ?? this.teamB,
scoreA: scoreA ?? this.scoreA,
scoreB: scoreB ?? this.scoreB,
status: status ?? this.status,
scheduledAt: scheduledAt ?? this.scheduledAt,
winnerId: winnerId ?? this.winnerId,
);
}
factory BracketMatch.fromMap(Map<String, dynamic> data) {
return BracketMatch(
id: (data['id'] as String?) ?? '',
teamA: data['team_a'] is Map<String, dynamic>
? BracketTeam.fromMap(data['team_a'] as Map<String, dynamic>)
: null,
teamB: data['team_b'] is Map<String, dynamic>
? BracketTeam.fromMap(data['team_b'] as Map<String, dynamic>)
: null,
scoreA: (data['score_a'] as num?)?.toInt(),
scoreB: (data['score_b'] as num?)?.toInt(),
status: _readStatus(data['status']),
scheduledAt: _readTimestamp(data['scheduled_at']),
winnerId: data['winner_id'] as String?,
);
}
Map<String, Object?> toMap() {
return <String, Object?>{
'id': id,
'team_a': teamA?.toMap(),
'team_b': teamB?.toMap(),
'score_a': scoreA,
'score_b': scoreB,
'status': status.name,
'scheduled_at': scheduledAt?.toIso8601String(),
'winner_id': winnerId,
};
}
static MatchStatus _readStatus(Object? value) {
if (value is String) {
for (final s in MatchStatus.values) {
if (s.name == value) return s;
}
}
return MatchStatus.scheduled;
}
static DateTime? _readTimestamp(Object? value) {
if (value is String && value.isNotEmpty) return DateTime.tryParse(value);
if (value is DateTime) return value;
return null;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BracketMatch &&
other.id == id &&
other.teamA == teamA &&
other.teamB == teamB &&
other.scoreA == scoreA &&
other.scoreB == scoreB &&
other.status == status &&
other.scheduledAt == scheduledAt &&
other.winnerId == winnerId;
}
@override
int get hashCode => Object.hash(
id,
teamA,
teamB,
scoreA,
scoreB,
status,
scheduledAt,
winnerId,
);
}
/// A round (column) in a bracket — quarterfinals, semifinals, final, etc.
class BracketRound {
const BracketRound({
required this.roundNumber,
required this.label,
required this.matches,
});
final int roundNumber;
final String label;
final List<BracketMatch> matches;
BracketRound copyWith({
int? roundNumber,
String? label,
List<BracketMatch>? matches,
}) {
return BracketRound(
roundNumber: roundNumber ?? this.roundNumber,
label: label ?? this.label,
matches: matches ?? this.matches,
);
}
factory BracketRound.fromMap(Map<String, dynamic> data) {
final rawMatches = (data['matches'] as List?) ?? const [];
return BracketRound(
roundNumber: (data['round_number'] as num?)?.toInt() ?? 0,
label: (data['label'] as String?) ?? '',
matches: rawMatches
.whereType<Map<String, dynamic>>()
.map(BracketMatch.fromMap)
.toList(growable: false),
);
}
Map<String, Object?> toMap() {
return <String, Object?>{
'round_number': roundNumber,
'label': label,
'matches': matches.map((m) => m.toMap()).toList(growable: false),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! BracketRound) return false;
if (other.roundNumber != roundNumber) return false;
if (other.label != label) return false;
if (other.matches.length != matches.length) return false;
for (var i = 0; i < matches.length; i++) {
if (other.matches[i] != matches[i]) return false;
}
return true;
}
@override
int get hashCode =>
Object.hash(roundNumber, label, Object.hashAll(matches));
}
/// Top-level bracket. A single event may have multiple brackets (e.g. main
/// draw + consolation), so brackets carry an [eventId].
class Bracket {
const Bracket({
required this.id,
required this.eventId,
required this.name,
required this.rounds,
required this.createdAt,
});
final String id;
final String eventId;
final String name;
final List<BracketRound> rounds;
final DateTime createdAt;
Bracket copyWith({
String? id,
String? eventId,
String? name,
List<BracketRound>? rounds,
DateTime? createdAt,
}) {
return Bracket(
id: id ?? this.id,
eventId: eventId ?? this.eventId,
name: name ?? this.name,
rounds: rounds ?? this.rounds,
createdAt: createdAt ?? this.createdAt,
);
}
factory Bracket.fromJson(Map<String, dynamic> data) {
final rawRounds = (data['rounds'] as List?) ?? const [];
return Bracket(
id: (data['id'] as String?) ?? '',
eventId: (data['event_id'] as String?) ?? '',
name: (data['name'] as String?) ?? '',
rounds: rawRounds
.whereType<Map<String, dynamic>>()
.map(BracketRound.fromMap)
.toList(growable: false),
createdAt: _readDate(data['created_at']) ?? DateTime.now(),
);
}
Map<String, Object?> toJson() {
return <String, Object?>{
'event_id': eventId,
'name': name,
'rounds': rounds.map((r) => r.toMap()).toList(growable: false),
'created_at': createdAt.toIso8601String(),
};
}
static DateTime? _readDate(Object? value) {
if (value is String && value.isNotEmpty) return DateTime.tryParse(value);
if (value is DateTime) return value;
return null;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! Bracket) return false;
if (other.id != id) return false;
if (other.eventId != eventId) return false;
if (other.name != name) return false;
if (other.createdAt != createdAt) return false;
if (other.rounds.length != rounds.length) return false;
for (var i = 0; i < rounds.length; i++) {
if (other.rounds[i] != rounds[i]) return false;
}
return true;
}
@override
int get hashCode =>
Object.hash(id, eventId, name, createdAt, Object.hashAll(rounds));
@override
String toString() => 'Bracket(id: $id, name: $name, rounds: ${rounds.length})';
}
@@ -0,0 +1,80 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/api/api_client.dart';
import '../domain/bracket.dart';
part 'brackets_repository.g.dart';
class BracketsRepository {
BracketsRepository(this._api);
final ApiClient _api;
Future<List<Bracket>> fetchBrackets() async {
final data = await _api.get('/brackets/index.php');
final list = (data['brackets'] as List?) ?? [];
return list.whereType<Map<String, dynamic>>().map(Bracket.fromJson).toList();
}
Future<Bracket?> getBracket(String id) async {
try {
final data = await _api.get('/brackets/detail.php', params: {'id': id});
return Bracket.fromJson(data);
} on ApiException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
Future<String> createBracket(Bracket bracket) async {
final data = await _api.post('/brackets/index.php', bracket.toJson());
return data['id'] as String;
}
Future<Bracket> updateBracket(Bracket bracket) async {
final data = await _api.put(
'/brackets/detail.php',
bracket.toJson(),
params: {'id': bracket.id},
);
return Bracket.fromJson(data);
}
Future<void> deleteBracket(String id) async {
await _api.delete('/brackets/detail.php', params: {'id': id});
}
Future<void> updateMatch(
String bracketId,
String roundLabel,
BracketMatch match,
) async {
final bracket = await getBracket(bracketId);
if (bracket == null) return;
final rounds = bracket.rounds.map((round) {
if (round.label != roundLabel) return round;
final updatedMatches = round.matches
.map((m) => m.id == match.id ? match : m)
.toList(growable: false);
return round.copyWith(matches: updatedMatches);
}).toList(growable: false);
await updateBracket(bracket.copyWith(rounds: rounds));
}
Stream<List<Bracket>> watchBrackets() async* {
yield await fetchBrackets();
await for (final _ in Stream<void>.periodic(const Duration(seconds: 30))) {
yield await fetchBrackets();
}
}
}
@Riverpod(keepAlive: true)
BracketsRepository bracketsRepository(BracketsRepositoryRef ref) {
return BracketsRepository(ref.watch(apiClientProvider));
}
@riverpod
Stream<List<Bracket>> bracketsStream(BracketsStreamRef ref) {
return ref.watch(bracketsRepositoryProvider).watchBrackets();
}
@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'brackets_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$bracketsRepositoryHash() =>
r'942ebdb136bee1840c05c7d263e6a4e530cc2d4d';
/// See also [bracketsRepository].
@ProviderFor(bracketsRepository)
final bracketsRepositoryProvider = Provider<BracketsRepository>.internal(
bracketsRepository,
name: r'bracketsRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bracketsRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BracketsRepositoryRef = ProviderRef<BracketsRepository>;
String _$bracketsStreamHash() => r'72d5d17ad76cbfcf900c81d6bcf44f6678e52dfa';
/// Stream of brackets surfaced to the UI. Currently emits the mock list as a
/// single tick — swap to `ref.watch(bracketsRepositoryProvider).watchBrackets()`
/// once Firestore is seeded.
///
/// Copied from [bracketsStream].
@ProviderFor(bracketsStream)
final bracketsStreamProvider =
AutoDisposeStreamProvider<List<Bracket>>.internal(
bracketsStream,
name: r'bracketsStreamProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bracketsStreamHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BracketsStreamRef = AutoDisposeStreamProviderRef<List<Bracket>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -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,
),
),
],
),
),
],
),
);
}
}