From bc35ec9c01030dc783dc9df413c6da2639de006a Mon Sep 17 00:00:00 2001 From: Tim Kainz Date: Thu, 12 Mar 2026 15:05:13 +0100 Subject: [PATCH] Adding match endpoints, services and providers, additionally added a page in the frontend that displays the data --- backend_splatournament_manager/src/app.ts | 54 +++ .../src/models/match.ts | 9 + .../src/services/match-service.ts | 220 ++++++++++++ docs/prompt.md | 10 + frontend_splatournament_manager/lib/main.dart | 2 + .../lib/models/match.dart | 46 +++ .../lib/pages/tournament_bracket_page.dart | 316 ++++++++++++++++-- .../lib/providers/match_provider.dart | 52 +++ 8 files changed, 675 insertions(+), 34 deletions(-) create mode 100644 backend_splatournament_manager/src/models/match.ts create mode 100644 backend_splatournament_manager/src/services/match-service.ts create mode 100644 frontend_splatournament_manager/lib/models/match.dart create mode 100644 frontend_splatournament_manager/lib/providers/match_provider.dart diff --git a/backend_splatournament_manager/src/app.ts b/backend_splatournament_manager/src/app.ts index 6c9ea36..6d6b545 100644 --- a/backend_splatournament_manager/src/app.ts +++ b/backend_splatournament_manager/src/app.ts @@ -5,6 +5,7 @@ import 'dotenv/config'; import {TournamentService} from './services/tournament-service'; import {UserService} from './services/user-service'; import {TeamService} from './services/team-service'; +import {MatchService} from './services/match-service'; import {authMiddleware} from './middlewares/auth-middleware'; import loggingMiddleware from './middlewares/logger'; import {Database} from 'sqlite3'; @@ -21,6 +22,7 @@ const db = new Database(dbFilename); const tournamentService = new TournamentService(db); const userService = new UserService(db); const teamService = new TeamService(db); +const matchService = new MatchService(db); const port = process.env.PORT || 3000; const app = express(); @@ -68,6 +70,58 @@ app.delete('/tournaments/:id', authMiddleware, async (req: Request, res: Respons res.status(200).send({message: 'Tournament deleted successfully'}); }); +app.post('/tournaments/:id/bracket', authMiddleware, async (req: Request, res: Response) => { + try { + const tournamentId = +req.params.id; + const teams = await teamService.getTeamsByTournamentId(tournamentId); + const teamIds = teams.map(team => team.id); + + if (teamIds.length < 2) { + return res.status(400).send({error: 'At least 2 teams are required to initialize bracket'}); + } + + await matchService.initializeBracket(tournamentId, teamIds); + res.status(201).send({message: 'Bracket initialized successfully'}); + } catch (err) { + console.log(err); + res.status(400).send({error: 'Failed to initialize bracket'}); + } +}); + +app.get('/tournaments/:id/matches', async (req: Request, res: Response) => { + try { + const matches = await matchService.getMatchesByTournament(+req.params.id); + res.send(matches); + } catch (err) { + console.log(err); + res.status(400).send({error: 'Failed to get matches'}); + } +}); + +app.put('/matches/:id/winner', authMiddleware, async (req: Request, res: Response) => { + try { + const {winnerId} = req.body; + if (!winnerId) { + return res.status(400).send({error: 'winnerId is required'}); + } + await matchService.setMatchWinner(+req.params.id, +winnerId); + res.status(200).send({message: 'Winner set successfully'}); + } catch (err: any) { + console.log(err); + res.status(400).send({error: err.message || 'Failed to set winner'}); + } +}); + +app.delete('/matches/:id/winner', authMiddleware, async (req: Request, res: Response) => { + try { + await matchService.resetMatch(+req.params.id); + res.status(200).send({message: 'Match reset successfully'}); + } catch (err) { + console.log(err); + res.status(400).send({error: 'Failed to reset match'}); + } +}); + app.get('/teams', async (req: Request, res: Response) => { const teams = await teamService.getAllTeams(); res.send(teams); diff --git a/backend_splatournament_manager/src/models/match.ts b/backend_splatournament_manager/src/models/match.ts new file mode 100644 index 0000000..3e69c6e --- /dev/null +++ b/backend_splatournament_manager/src/models/match.ts @@ -0,0 +1,9 @@ +export interface Match { + id: number; + tournamentId: number; + round: number; + matchNumber: number; + team1Id: number | null; + team2Id: number | null; + winnerId: number | null; +} diff --git a/backend_splatournament_manager/src/services/match-service.ts b/backend_splatournament_manager/src/services/match-service.ts new file mode 100644 index 0000000..2a4b8df --- /dev/null +++ b/backend_splatournament_manager/src/services/match-service.ts @@ -0,0 +1,220 @@ +import { Match } from '../models/match'; +import { Database } from 'sqlite3'; + +export class MatchService { + private db: Database; + + constructor(db: Database) { + this.db = db; + this.db.serialize(() => { + this.db.run(`CREATE TABLE IF NOT EXISTS Matches + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournamentId INTEGER NOT NULL, + round INTEGER NOT NULL, + matchNumber INTEGER NOT NULL, + team1Id INTEGER, + team2Id INTEGER, + winnerId INTEGER, + FOREIGN KEY (tournamentId) REFERENCES Tournaments(id) ON DELETE CASCADE, + FOREIGN KEY (team1Id) REFERENCES Teams(id) ON DELETE SET NULL, + FOREIGN KEY (team2Id) REFERENCES Teams(id) ON DELETE SET NULL, + FOREIGN KEY (winnerId) REFERENCES Teams(id) ON DELETE SET NULL + )`); + }); + } + + /** + * Initialize bracket for a tournament based on registered teams + */ + initializeBracket(tournamentId: number, teamIds: number[]): Promise { + return new Promise((resolve, reject) => { + // Determine bracket size (2, 4, or 8) + const teamCount = teamIds.length; + let bracketSize: number; + if (teamCount <= 2) bracketSize = 2; + else if (teamCount <= 4) bracketSize = 4; + else bracketSize = 8; + + // Calculate number of rounds + let roundCount: number; + if (bracketSize === 2) roundCount = 1; + else if (bracketSize === 4) roundCount = 2; + else roundCount = 3; + + // First, check if matches already exist for this tournament + this.db.get( + 'SELECT COUNT(*) as count FROM Matches WHERE tournamentId = ?', + [tournamentId], + (err, row: any) => { + if (err) return reject(err); + if (row.count > 0) { + // Matches already initialized + return resolve(); + } + + // Create first round matches + const statement = this.db.prepare( + 'INSERT INTO Matches (tournamentId, round, matchNumber, team1Id, team2Id, winnerId) VALUES (?, ?, ?, ?, ?, ?)' + ); + + const firstRoundMatches = bracketSize / 2; + for (let i = 0; i < firstRoundMatches; i++) { + const team1Id = i * 2 < teamIds.length ? teamIds[i * 2] : null; + const team2Id = i * 2 + 1 < teamIds.length ? teamIds[i * 2 + 1] : null; + statement.run(tournamentId, 0, i, team1Id, team2Id, null); + } + + // Create placeholder matches for subsequent rounds + for (let round = 1; round < roundCount; round++) { + const matchesInRound = bracketSize / Math.pow(2, round + 1); + for (let i = 0; i < matchesInRound; i++) { + statement.run(tournamentId, round, i, null, null, null); + } + } + + // Create winner slot (final round) + statement.run(tournamentId, roundCount, 0, null, null, null, (err: Error | null) => { + if (err) return reject(err); + statement.finalize(); + resolve(); + }); + } + ); + }); + } + + /** + * Get all matches for a tournament + */ + getMatchesByTournament(tournamentId: number): Promise { + return new Promise((resolve, reject) => { + this.db.all( + 'SELECT * FROM Matches WHERE tournamentId = ? ORDER BY round, matchNumber', + [tournamentId], + (err: Error | null, rows: any[]) => { + if (err) return reject(err); + const matches: Match[] = rows.map(row => ({ + id: row.id, + tournamentId: row.tournamentId, + round: row.round, + matchNumber: row.matchNumber, + team1Id: row.team1Id, + team2Id: row.team2Id, + winnerId: row.winnerId, + })); + resolve(matches); + } + ); + }); + } + + /** + * Set the winner of a match and progress them to the next round + */ + setMatchWinner(matchId: number, winnerId: number): Promise { + return new Promise((resolve, reject) => { + // First, get the match details + this.db.get( + 'SELECT * FROM Matches WHERE id = ?', + [matchId], + (err, match: any) => { + if (err) return reject(err); + if (!match) return reject(new Error('Match not found')); + + // Validate that the winnerId is one of the participants + if (match.team1Id !== winnerId && match.team2Id !== winnerId) { + return reject(new Error('Winner must be one of the match participants')); + } + + // Update the match with winner + this.db.run( + 'UPDATE Matches SET winnerId = ? WHERE id = ?', + [winnerId, matchId], + (err) => { + if (err) return reject(err); + + // Progress winner to next round + const nextRound = match.round + 1; + const nextMatchNumber = Math.floor(match.matchNumber / 2); + + // Find the next match + this.db.get( + 'SELECT * FROM Matches WHERE tournamentId = ? AND round = ? AND matchNumber = ?', + [match.tournamentId, nextRound, nextMatchNumber], + (err, nextMatch: any) => { + if (err) return reject(err); + if (!nextMatch) { + // This was the final match, no next round + return resolve(); + } + + // Determine if winner goes to team1 or team2 slot + const isEvenMatch = match.matchNumber % 2 === 0; + const field = isEvenMatch ? 'team1Id' : 'team2Id'; + + // Update next match with winner + this.db.run( + `UPDATE Matches SET ${field} = ? WHERE id = ?`, + [winnerId, nextMatch.id], + (err) => { + if (err) return reject(err); + resolve(); + } + ); + } + ); + } + ); + } + ); + }); + } + + /** + * Reset a match (clear winner and remove from subsequent rounds) + */ + resetMatch(matchId: number): Promise { + return new Promise((resolve, reject) => { + // Get match details + this.db.get( + 'SELECT * FROM Matches WHERE id = ?', + [matchId], + (err, match: any) => { + if (err) return reject(err); + if (!match) return reject(new Error('Match not found')); + + const previousWinnerId = match.winnerId; + + // Clear the winner + this.db.run( + 'UPDATE Matches SET winnerId = NULL WHERE id = ?', + [matchId], + (err) => { + if (err) return reject(err); + + if (!previousWinnerId) { + return resolve(); + } + + // Remove winner from next round + const nextRound = match.round + 1; + const nextMatchNumber = Math.floor(match.matchNumber / 2); + const isEvenMatch = match.matchNumber % 2 === 0; + const field = isEvenMatch ? 'team1Id' : 'team2Id'; + + this.db.run( + `UPDATE Matches SET ${field} = NULL WHERE tournamentId = ? AND round = ? AND matchNumber = ?`, + [match.tournamentId, nextRound, nextMatchNumber], + (err) => { + if (err) return reject(err); + resolve(); + } + ); + } + ); + } + ); + }); + } +} diff --git a/docs/prompt.md b/docs/prompt.md index d56043f..90e0af0 100644 --- a/docs/prompt.md +++ b/docs/prompt.md @@ -100,3 +100,13 @@ Folgende Dateien wurden in diesem Prompt verändert: Folgende Dateien wurden in diesem Prompt verändert: - frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart (neu erstellt) - frontend_splatournament_manager/lib/pages/tournament_detail_page.dart + +- add the ability to progress in a tournament

+Folgende Dateien wurden in diesem Prompt verändert: + - backend_splatournament_manager/src/models/match.ts (neu erstellt) + - backend_splatournament_manager/src/services/match-service.ts (neu erstellt) + - backend_splatournament_manager/src/app.ts + - frontend_splatournament_manager/lib/models/match.dart (neu erstellt) + - frontend_splatournament_manager/lib/providers/match_provider.dart (neu erstellt) + - frontend_splatournament_manager/lib/main.dart + - frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart diff --git a/frontend_splatournament_manager/lib/main.dart b/frontend_splatournament_manager/lib/main.dart index 3e01744..1fc305e 100644 --- a/frontend_splatournament_manager/lib/main.dart +++ b/frontend_splatournament_manager/lib/main.dart @@ -3,6 +3,7 @@ import 'package:frontend_splatournament_manager/pages/home_page.dart'; import 'package:frontend_splatournament_manager/pages/login_page.dart'; import 'package:frontend_splatournament_manager/pages/settings_page.dart'; import 'package:frontend_splatournament_manager/providers/auth_provider.dart'; +import 'package:frontend_splatournament_manager/providers/match_provider.dart'; import 'package:frontend_splatournament_manager/providers/team_provider.dart'; import 'package:frontend_splatournament_manager/providers/theme_provider.dart'; import 'package:frontend_splatournament_manager/providers/tournament_provider.dart'; @@ -17,6 +18,7 @@ void main() { ChangeNotifierProvider(create: (_) => TournamentProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => TeamProvider()), + ChangeNotifierProvider(create: (_) => MatchProvider()), ], child: const SplatournamentApp(), ), diff --git a/frontend_splatournament_manager/lib/models/match.dart b/frontend_splatournament_manager/lib/models/match.dart new file mode 100644 index 0000000..889b34f --- /dev/null +++ b/frontend_splatournament_manager/lib/models/match.dart @@ -0,0 +1,46 @@ +class Match { + final int id; + final int tournamentId; + final int round; + final int matchNumber; + final int? team1Id; + final int? team2Id; + final int? winnerId; + + Match({ + required this.id, + required this.tournamentId, + required this.round, + required this.matchNumber, + this.team1Id, + this.team2Id, + this.winnerId, + }); + + factory Match.fromJson(Map json) { + return Match( + id: json['id'], + tournamentId: json['tournamentId'], + round: json['round'], + matchNumber: json['matchNumber'], + team1Id: json['team1Id'], + team2Id: json['team2Id'], + winnerId: json['winnerId'], + ); + } + + Map toJson() { + return { + 'id': id, + 'tournamentId': tournamentId, + 'round': round, + 'matchNumber': matchNumber, + 'team1Id': team1Id, + 'team2Id': team2Id, + 'winnerId': winnerId, + }; + } + + bool get hasWinner => winnerId != null; + bool get canBePlayed => team1Id != null && team2Id != null; +} diff --git a/frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart b/frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart index 11af7c4..752e6d7 100644 --- a/frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart +++ b/frontend_splatournament_manager/lib/pages/tournament_bracket_page.dart @@ -1,14 +1,21 @@ 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 StatelessWidget { +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; @@ -21,14 +28,95 @@ class TournamentBracketPage extends StatelessWidget { 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('Bracket initialized successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to initialize bracket: $e')), + ); + } + } + } + + Future _showWinnerDialog(Match match, Team team1, Team team2) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Winner'), + 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('Reset'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + + 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 reset' : 'Winner set successfully'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + } + @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(tournament.name)), - body: FutureBuilder>( - future: teamProvider.getTeamsByTournament(tournament.id), + 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()); @@ -37,10 +125,43 @@ class TournamentBracketPage extends StatelessWidget { return Center(child: Text('Error: ${snapshot.error}')); } - final teams = snapshot.data ?? []; - final bracketSize = _bracketSize(tournament.maxTeamAmount); + 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( + 'Bracket not initialized yet', + style: TextStyle(fontSize: 18), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: teams.length >= 2 ? _initializeBracket : null, + child: const Text('Initialize Bracket'), + ), + if (teams.length < 2) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Need at least 2 teams', + style: 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( @@ -49,9 +170,11 @@ class TournamentBracketPage extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.vertical, child: _BracketBoard( - teams: teams, + matches: matches, + teamMap: teamMap, bracketSize: bracketSize, roundCount: roundCount, + onMatchTap: _showWinnerDialog, ), ), ), @@ -63,9 +186,11 @@ class TournamentBracketPage extends StatelessWidget { } class _BracketBoard extends StatelessWidget { - final List teams; + 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; @@ -75,9 +200,11 @@ class _BracketBoard extends StatelessWidget { static const double _lineThickness = 2; const _BracketBoard({ - required this.teams, + required this.matches, + required this.teamMap, required this.bracketSize, required this.roundCount, + required this.onMatchTap, }); String _roundLabel(int round) { @@ -96,6 +223,16 @@ class _BracketBoard extends StatelessWidget { 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; @@ -107,10 +244,12 @@ class _BracketBoard extends StatelessWidget { final children = []; + // Build bracket for (int round = 0; round < roundCount; round++) { final cardsInRound = bracketSize ~/ (1 << round); final left = round * (_cardWidth + _connectorWidth); + // Round label children.add( Positioned( left: left, @@ -131,24 +270,43 @@ class _BracketBoard extends StatelessWidget { ), ); + // Match cards for (int i = 0; i < cardsInRound; i++) { - final String? label = (round == 0 && i < teams.length) - ? teams[i].name - : null; + final match = _findMatch(round, i); + children.add( Positioned( left: left, top: _cardTop(round, i), width: _cardWidth, height: _cardHeight, - child: _TeamCard(label: label), + child: _MatchCard( + match: match, + teamMap: teamMap, + onTap: 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, + ), ), ); } - if (round == roundCount - 1) { - continue; - } + // Draw connectors + if (round == roundCount - 1) continue; final connectorLeft = left + _cardWidth; final matches = cardsInRound ~/ 2; @@ -159,6 +317,7 @@ class _BracketBoard extends StatelessWidget { final yBottom = _cardCenterY(round, i * 2 + 1); final yMiddle = (yTop + yBottom) / 2; + // Top horizontal line children.add( Positioned( left: connectorLeft, @@ -169,6 +328,7 @@ class _BracketBoard extends StatelessWidget { ), ); + // Bottom horizontal line children.add( Positioned( left: connectorLeft, @@ -179,6 +339,7 @@ class _BracketBoard extends StatelessWidget { ), ); + // Vertical line children.add( Positioned( left: connectorLeft + halfConnector - _lineThickness / 2, @@ -189,6 +350,7 @@ class _BracketBoard extends StatelessWidget { ), ); + // Middle horizontal line to next round children.add( Positioned( left: connectorLeft + halfConnector, @@ -209,36 +371,122 @@ class _BracketBoard extends StatelessWidget { } } -class _TeamCard extends StatelessWidget { - final String? label; +class _MatchCard extends StatelessWidget { + final Match? match; + final Map teamMap; + final VoidCallback? onTap; - const _TeamCard({this.label}); + const _MatchCard({ + this.match, + required this.teamMap, + this.onTap, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final isTbd = label == null; - return Card( - elevation: isTbd ? 1 : 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + if (match == null) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Center( child: Text( - isTbd ? '?' : label!, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + '?', style: TextStyle( fontSize: 13, - fontWeight: isTbd ? FontWeight.w400 : FontWeight.w600, - color: isTbd - ? colorScheme.onSurface.withValues(alpha: 0.38) - : colorScheme.onSurface, + 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: 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 (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, ), ); } diff --git a/frontend_splatournament_manager/lib/providers/match_provider.dart b/frontend_splatournament_manager/lib/providers/match_provider.dart new file mode 100644 index 0000000..8ec77ee --- /dev/null +++ b/frontend_splatournament_manager/lib/providers/match_provider.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:frontend_splatournament_manager/models/match.dart'; +import 'package:frontend_splatournament_manager/services/api_client.dart'; + +class MatchProvider extends ChangeNotifier { + Future initializeBracket(int tournamentId) async { + final response = await ApiClient.post('/tournaments/$tournamentId/bracket', {}); + + if (response.statusCode != HttpStatus.created) { + throw Exception('Failed to initialize bracket (${response.statusCode})'); + } + + notifyListeners(); + } + + Future> getMatchesByTournament(int tournamentId) async { + final response = await ApiClient.get('/tournaments/$tournamentId/matches'); + + if (response.statusCode != HttpStatus.ok) { + throw Exception('Failed to load matches (${response.statusCode})'); + } + + final List list = json.decode(response.body); + return list.map((json) => Match.fromJson(json)).toList(); + } + + Future setMatchWinner(int matchId, int winnerId) async { + final response = await ApiClient.put('/matches/$matchId/winner', { + 'winnerId': winnerId, + }); + + if (response.statusCode != HttpStatus.ok) { + final error = json.decode(response.body); + throw Exception(error['error'] ?? 'Failed to set winner'); + } + + notifyListeners(); + } + + Future resetMatch(int matchId) async { + final response = await ApiClient.delete('/matches/$matchId/winner'); + + if (response.statusCode != HttpStatus.ok) { + throw Exception('Failed to reset match (${response.statusCode})'); + } + + notifyListeners(); + } +}