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 createState() => _TournamentBracketPageState(); } class _TournamentBracketPageState extends State { 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 _initializeBracket() async { final matchProvider = Provider.of(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 _showWinnerDialog(Match match, Team team1, Team team2) async { final result = await showDialog( 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(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(context, listen: false); final matchProvider = Provider.of(context, listen: false); return Scaffold( appBar: AppBar(title: Text(widget.tournament.name)), body: FutureBuilder>( 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; final matches = snapshot.data![1] as List; // 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 matches; final Map 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 = []; // 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 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, ), ); } }