539 lines
16 KiB
Dart
539 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:frontend_splatournament_manager/models/match.dart';
|
|
import 'package:frontend_splatournament_manager/models/team.dart';
|
|
import 'package:frontend_splatournament_manager/models/tournament.dart';
|
|
import 'package:frontend_splatournament_manager/providers/match_provider.dart';
|
|
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class TournamentBracketPage extends StatefulWidget {
|
|
final Tournament tournament;
|
|
|
|
const TournamentBracketPage({super.key, required this.tournament});
|
|
|
|
@override
|
|
State<TournamentBracketPage> createState() => _TournamentBracketPageState();
|
|
}
|
|
|
|
class _TournamentBracketPageState extends State<TournamentBracketPage> {
|
|
int _bracketSize(int n) {
|
|
if (n <= 2) return 2;
|
|
if (n <= 4) return 4;
|
|
return 8;
|
|
}
|
|
|
|
int _roundCount(int bracketSize) {
|
|
if (bracketSize == 2) return 2;
|
|
if (bracketSize == 4) return 3;
|
|
return 4;
|
|
}
|
|
|
|
Future<void> _initializeBracket() async {
|
|
final matchProvider = Provider.of<MatchProvider>(context, listen: false);
|
|
try {
|
|
await matchProvider.initializeBracket(widget.tournament.id);
|
|
if (mounted) {
|
|
setState(() {});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Turnierbaum erfolgreich initialisiert'),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Initialisierung des Turnierbaums fehlgeschlagen: ${e.toString().replaceFirst('Exception: ', '')}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showWinnerDialog(Match match, Team team1, Team team2) async {
|
|
final result = await showDialog<int>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Sieger auswählen'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
title: Text(team1.name),
|
|
onTap: () => Navigator.pop(context, team1.id),
|
|
),
|
|
ListTile(
|
|
title: Text(team2.name),
|
|
onTap: () => Navigator.pop(context, team2.id),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
if (match.hasWinner)
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, -1), // Reset signal
|
|
child: const Text('Zurücksetzen'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Abbrechen'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != null && mounted) {
|
|
final matchProvider = Provider.of<MatchProvider>(context, listen: false);
|
|
try {
|
|
if (result == -1) {
|
|
await matchProvider.resetMatch(match.id);
|
|
} else {
|
|
await matchProvider.setMatchWinner(match.id, result);
|
|
}
|
|
setState(() {});
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
result == -1
|
|
? 'Match zurückgesetzt'
|
|
: 'Sieger erfolgreich festgelegt',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Fehler: ${e.toString().replaceFirst('Exception: ', '')}',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final teamProvider = Provider.of<TeamProvider>(context, listen: false);
|
|
final matchProvider = Provider.of<MatchProvider>(context, listen: false);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(widget.tournament.name)),
|
|
body: FutureBuilder<List<dynamic>>(
|
|
future: Future.wait([
|
|
teamProvider.getTeamsByTournament(widget.tournament.id),
|
|
matchProvider.getMatchesByTournament(widget.tournament.id),
|
|
]),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError) {
|
|
return Center(
|
|
child: Text(
|
|
'Fehler: ${snapshot.error.toString().replaceFirst('Exception: ', '')}',
|
|
),
|
|
);
|
|
}
|
|
|
|
final teams = snapshot.data![0] as List<Team>;
|
|
final matches = snapshot.data![1] as List<Match>;
|
|
|
|
// Check if bracket is initialized
|
|
if (matches.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Der Turnierbaum wurde noch nicht initialisiert',
|
|
style: TextStyle(fontSize: 18),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: teams.length >= widget.tournament.maxTeamAmount
|
|
? _initializeBracket
|
|
: null,
|
|
child: const Text('Turnierbaum initialisieren'),
|
|
),
|
|
if (teams.length < widget.tournament.maxTeamAmount)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
'${teams.length}/${widget.tournament.maxTeamAmount} Teams angemeldet',
|
|
style: const TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final bracketSize = _bracketSize(teams.length);
|
|
final roundCount = _roundCount(bracketSize);
|
|
|
|
// Create a map of team IDs to Team objects for easy lookup
|
|
final teamMap = {for (var team in teams) team.id: team};
|
|
|
|
return Scrollbar(
|
|
thumbVisibility: true,
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.all(24),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.vertical,
|
|
child: _BracketBoard(
|
|
matches: matches,
|
|
teamMap: teamMap,
|
|
bracketSize: bracketSize,
|
|
roundCount: roundCount,
|
|
onMatchTap: _showWinnerDialog,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BracketBoard extends StatelessWidget {
|
|
final List<Match> matches;
|
|
final Map<int, Team> teamMap;
|
|
final int bracketSize;
|
|
final int roundCount;
|
|
final Function(Match, Team, Team) onMatchTap;
|
|
|
|
static const double _cardWidth = 112;
|
|
static const double _cardHeight = 96;
|
|
static const double _baseSlotHeight = 128;
|
|
static const double _connectorWidth = 48;
|
|
static const double _headerHeight = 28;
|
|
static const double _lineThickness = 2;
|
|
|
|
const _BracketBoard({
|
|
required this.matches,
|
|
required this.teamMap,
|
|
required this.bracketSize,
|
|
required this.roundCount,
|
|
required this.onMatchTap,
|
|
});
|
|
|
|
String _roundLabel(int round) {
|
|
if (round == roundCount - 1) return 'Sieger';
|
|
final teamsInRound = bracketSize ~/ (1 << round);
|
|
if (teamsInRound == 2) return 'Finale';
|
|
if (teamsInRound == 4) return 'Halbfinale';
|
|
return 'Viertelfinale';
|
|
}
|
|
|
|
double _cardTop(int round, int index) {
|
|
if (round == roundCount - 1) {
|
|
final contentHeight = bracketSize * _baseSlotHeight.toDouble();
|
|
return _headerHeight + (contentHeight - _cardHeight) / 2;
|
|
}
|
|
final slotHeight = _baseSlotHeight * (1 << (round + 1)).toDouble();
|
|
return _headerHeight + index * slotHeight + (slotHeight - _cardHeight) / 2;
|
|
}
|
|
|
|
double _cardCenterY(int round, int index) =>
|
|
_cardTop(round, index) + _cardHeight / 2;
|
|
|
|
Match? _findMatch(int round, int matchNumber) {
|
|
try {
|
|
return matches.firstWhere(
|
|
(m) => m.round == round && m.matchNumber == matchNumber,
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final lineColor = colorScheme.outlineVariant;
|
|
|
|
final boardWidth =
|
|
roundCount * _cardWidth + (roundCount - 1) * _connectorWidth;
|
|
final boardHeight = _headerHeight + bracketSize * _baseSlotHeight;
|
|
|
|
final children = <Widget>[];
|
|
|
|
// Build bracket
|
|
for (int round = 0; round < roundCount; round++) {
|
|
final cardsInRound = round == roundCount - 1
|
|
? 1
|
|
: bracketSize ~/ (1 << (round + 1));
|
|
final left = round * (_cardWidth + _connectorWidth);
|
|
|
|
// Round label
|
|
children.add(
|
|
Positioned(
|
|
left: left,
|
|
top: 0,
|
|
width: _cardWidth,
|
|
height: _headerHeight,
|
|
child: Center(
|
|
child: Text(
|
|
_roundLabel(round),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: colorScheme.primary,
|
|
letterSpacing: 0.3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Match cards
|
|
for (int i = 0; i < cardsInRound; i++) {
|
|
final isSieger = round == roundCount - 1;
|
|
final match = isSieger
|
|
? _findMatch(round - 1, 0)
|
|
: _findMatch(round, i);
|
|
|
|
// Lock match if its downstream match already has a winner
|
|
final downstreamMatch = (!isSieger && round < roundCount - 2)
|
|
? _findMatch(round + 1, i ~/ 2)
|
|
: null;
|
|
final isLocked = downstreamMatch != null && downstreamMatch.hasWinner;
|
|
|
|
children.add(
|
|
Positioned(
|
|
left: left,
|
|
top: _cardTop(round, i),
|
|
width: _cardWidth,
|
|
height: _cardHeight,
|
|
child: _MatchCard(
|
|
match: match,
|
|
teamMap: teamMap,
|
|
showWinnerOnly: isSieger,
|
|
isLocked: isLocked,
|
|
onTap: isSieger || isLocked
|
|
? null
|
|
: match != null && match.canBePlayed && !match.hasWinner
|
|
? () {
|
|
final team1 = teamMap[match.team1Id];
|
|
final team2 = teamMap[match.team2Id];
|
|
if (team1 != null && team2 != null) {
|
|
onMatchTap(match, team1, team2);
|
|
}
|
|
}
|
|
: match != null && match.hasWinner
|
|
? () {
|
|
final team1 = teamMap[match.team1Id];
|
|
final team2 = teamMap[match.team2Id];
|
|
if (team1 != null && team2 != null) {
|
|
onMatchTap(match, team1, team2);
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Draw connectors
|
|
if (round == roundCount - 1) continue;
|
|
|
|
final connectorLeft = left + _cardWidth;
|
|
final matches = cardsInRound ~/ 2;
|
|
final halfConnector = _connectorWidth / 2;
|
|
|
|
for (int i = 0; i < matches; i++) {
|
|
final yTop = _cardCenterY(round, i * 2);
|
|
final yBottom = _cardCenterY(round, i * 2 + 1);
|
|
final yMiddle = (yTop + yBottom) / 2;
|
|
|
|
// Top horizontal line
|
|
children.add(
|
|
Positioned(
|
|
left: connectorLeft,
|
|
top: yTop - _lineThickness / 2,
|
|
width: halfConnector,
|
|
height: _lineThickness,
|
|
child: DecoratedBox(decoration: BoxDecoration(color: lineColor)),
|
|
),
|
|
);
|
|
|
|
// Bottom horizontal line
|
|
children.add(
|
|
Positioned(
|
|
left: connectorLeft,
|
|
top: yBottom - _lineThickness / 2,
|
|
width: halfConnector,
|
|
height: _lineThickness,
|
|
child: DecoratedBox(decoration: BoxDecoration(color: lineColor)),
|
|
),
|
|
);
|
|
|
|
// Vertical line
|
|
children.add(
|
|
Positioned(
|
|
left: connectorLeft + halfConnector - _lineThickness / 2,
|
|
top: yTop,
|
|
width: _lineThickness,
|
|
height: yBottom - yTop,
|
|
child: DecoratedBox(decoration: BoxDecoration(color: lineColor)),
|
|
),
|
|
);
|
|
|
|
// Middle horizontal line to next round
|
|
children.add(
|
|
Positioned(
|
|
left: connectorLeft + halfConnector,
|
|
top: yMiddle - _lineThickness / 2,
|
|
width: halfConnector,
|
|
height: _lineThickness,
|
|
child: DecoratedBox(decoration: BoxDecoration(color: lineColor)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return SizedBox(
|
|
width: boardWidth,
|
|
height: boardHeight,
|
|
child: Stack(children: children),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MatchCard extends StatelessWidget {
|
|
final Match? match;
|
|
final Map<int, Team> teamMap;
|
|
final VoidCallback? onTap;
|
|
final bool showWinnerOnly;
|
|
final bool isLocked;
|
|
|
|
const _MatchCard({
|
|
this.match,
|
|
required this.teamMap,
|
|
this.onTap,
|
|
this.showWinnerOnly = false,
|
|
this.isLocked = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
if (match == null) {
|
|
return Card(
|
|
elevation: 1,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Center(
|
|
child: Text(
|
|
'?',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w400,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.38),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final team1 = match!.team1Id != null ? teamMap[match!.team1Id] : null;
|
|
final team2 = match!.team2Id != null ? teamMap[match!.team2Id] : null;
|
|
final winner = match!.winnerId != null ? teamMap[match!.winnerId] : null;
|
|
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Card(
|
|
elevation: match!.hasWinner ? 3 : 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: isLocked
|
|
? BorderSide(color: colorScheme.outlineVariant, width: 1)
|
|
: match!.hasWinner
|
|
? BorderSide(color: colorScheme.primary, width: 2)
|
|
: BorderSide.none,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (!showWinnerOnly && team1 != null && team2 != null) ...[
|
|
_TeamLabel(
|
|
team: team1,
|
|
isWinner: match!.winnerId == team1.id,
|
|
colorScheme: colorScheme,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'vs.',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_TeamLabel(
|
|
team: team2,
|
|
isWinner: match!.winnerId == team2.id,
|
|
colorScheme: colorScheme,
|
|
),
|
|
] else if (winner != null) ...[
|
|
_TeamLabel(
|
|
team: winner,
|
|
isWinner: true,
|
|
colorScheme: colorScheme,
|
|
),
|
|
] else
|
|
Text(
|
|
'?',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w400,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.38),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TeamLabel extends StatelessWidget {
|
|
final Team team;
|
|
final bool isWinner;
|
|
final ColorScheme colorScheme;
|
|
|
|
const _TeamLabel({
|
|
required this.team,
|
|
required this.isWinner,
|
|
required this.colorScheme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text(
|
|
team.name,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: isWinner ? FontWeight.w700 : FontWeight.w500,
|
|
color: isWinner ? colorScheme.primary : colorScheme.onSurface,
|
|
),
|
|
);
|
|
}
|
|
}
|