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,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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user