Adding match endpoints, services and providers, additionally added a page in the frontend that displays the data
This commit is contained in:
@@ -5,6 +5,7 @@ import 'dotenv/config';
|
|||||||
import {TournamentService} from './services/tournament-service';
|
import {TournamentService} from './services/tournament-service';
|
||||||
import {UserService} from './services/user-service';
|
import {UserService} from './services/user-service';
|
||||||
import {TeamService} from './services/team-service';
|
import {TeamService} from './services/team-service';
|
||||||
|
import {MatchService} from './services/match-service';
|
||||||
import {authMiddleware} from './middlewares/auth-middleware';
|
import {authMiddleware} from './middlewares/auth-middleware';
|
||||||
import loggingMiddleware from './middlewares/logger';
|
import loggingMiddleware from './middlewares/logger';
|
||||||
import {Database} from 'sqlite3';
|
import {Database} from 'sqlite3';
|
||||||
@@ -21,6 +22,7 @@ const db = new Database(dbFilename);
|
|||||||
const tournamentService = new TournamentService(db);
|
const tournamentService = new TournamentService(db);
|
||||||
const userService = new UserService(db);
|
const userService = new UserService(db);
|
||||||
const teamService = new TeamService(db);
|
const teamService = new TeamService(db);
|
||||||
|
const matchService = new MatchService(db);
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const app = express();
|
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'});
|
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) => {
|
app.get('/teams', async (req: Request, res: Response) => {
|
||||||
const teams = await teamService.getAllTeams();
|
const teams = await teamService.getAllTeams();
|
||||||
res.send(teams);
|
res.send(teams);
|
||||||
|
|||||||
9
backend_splatournament_manager/src/models/match.ts
Normal file
9
backend_splatournament_manager/src/models/match.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Match {
|
||||||
|
id: number;
|
||||||
|
tournamentId: number;
|
||||||
|
round: number;
|
||||||
|
matchNumber: number;
|
||||||
|
team1Id: number | null;
|
||||||
|
team2Id: number | null;
|
||||||
|
winnerId: number | null;
|
||||||
|
}
|
||||||
220
backend_splatournament_manager/src/services/match-service.ts
Normal file
220
backend_splatournament_manager/src/services/match-service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<Match[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,3 +100,13 @@ Folgende Dateien wurden in diesem Prompt verändert:
|
|||||||
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_bracket_page.dart (neu erstellt)
|
||||||
- frontend_splatournament_manager/lib/pages/tournament_detail_page.dart
|
- frontend_splatournament_manager/lib/pages/tournament_detail_page.dart
|
||||||
|
|
||||||
|
- add the ability to progress in a tournament<br><br>
|
||||||
|
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
|
||||||
|
|||||||
@@ -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/login_page.dart';
|
||||||
import 'package:frontend_splatournament_manager/pages/settings_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/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/team_provider.dart';
|
||||||
import 'package:frontend_splatournament_manager/providers/theme_provider.dart';
|
import 'package:frontend_splatournament_manager/providers/theme_provider.dart';
|
||||||
import 'package:frontend_splatournament_manager/providers/tournament_provider.dart';
|
import 'package:frontend_splatournament_manager/providers/tournament_provider.dart';
|
||||||
@@ -17,6 +18,7 @@ void main() {
|
|||||||
ChangeNotifierProvider(create: (_) => TournamentProvider()),
|
ChangeNotifierProvider(create: (_) => TournamentProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => TeamProvider()),
|
ChangeNotifierProvider(create: (_) => TeamProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => MatchProvider()),
|
||||||
],
|
],
|
||||||
child: const SplatournamentApp(),
|
child: const SplatournamentApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
46
frontend_splatournament_manager/lib/models/match.dart
Normal file
46
frontend_splatournament_manager/lib/models/match.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import 'package:flutter/material.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/team.dart';
|
||||||
import 'package:frontend_splatournament_manager/models/tournament.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:frontend_splatournament_manager/providers/team_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class TournamentBracketPage extends StatelessWidget {
|
class TournamentBracketPage extends StatefulWidget {
|
||||||
final Tournament tournament;
|
final Tournament tournament;
|
||||||
|
|
||||||
const TournamentBracketPage({super.key, required this.tournament});
|
const TournamentBracketPage({super.key, required this.tournament});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TournamentBracketPage> createState() => _TournamentBracketPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TournamentBracketPageState extends State<TournamentBracketPage> {
|
||||||
int _bracketSize(int n) {
|
int _bracketSize(int n) {
|
||||||
if (n <= 2) return 2;
|
if (n <= 2) return 2;
|
||||||
if (n <= 4) return 4;
|
if (n <= 4) return 4;
|
||||||
@@ -21,14 +28,95 @@ class TournamentBracketPage extends StatelessWidget {
|
|||||||
return 4;
|
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('Bracket initialized successfully')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to initialize bracket: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showWinnerDialog(Match match, Team team1, Team team2) async {
|
||||||
|
final result = await showDialog<int>(
|
||||||
|
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<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 reset' : 'Winner set successfully'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final teamProvider = Provider.of<TeamProvider>(context, listen: false);
|
final teamProvider = Provider.of<TeamProvider>(context, listen: false);
|
||||||
|
final matchProvider = Provider.of<MatchProvider>(context, listen: false);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(tournament.name)),
|
appBar: AppBar(title: Text(widget.tournament.name)),
|
||||||
body: FutureBuilder<List<Team>>(
|
body: FutureBuilder<List<dynamic>>(
|
||||||
future: teamProvider.getTeamsByTournament(tournament.id),
|
future: Future.wait([
|
||||||
|
teamProvider.getTeamsByTournament(widget.tournament.id),
|
||||||
|
matchProvider.getMatchesByTournament(widget.tournament.id),
|
||||||
|
]),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -37,10 +125,43 @@ class TournamentBracketPage extends StatelessWidget {
|
|||||||
return Center(child: Text('Error: ${snapshot.error}'));
|
return Center(child: Text('Error: ${snapshot.error}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
final teams = snapshot.data ?? [];
|
final teams = snapshot.data![0] as List<Team>;
|
||||||
final bracketSize = _bracketSize(tournament.maxTeamAmount);
|
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(
|
||||||
|
'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);
|
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(
|
return Scrollbar(
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -49,9 +170,11 @@ class TournamentBracketPage extends StatelessWidget {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
child: _BracketBoard(
|
child: _BracketBoard(
|
||||||
teams: teams,
|
matches: matches,
|
||||||
|
teamMap: teamMap,
|
||||||
bracketSize: bracketSize,
|
bracketSize: bracketSize,
|
||||||
roundCount: roundCount,
|
roundCount: roundCount,
|
||||||
|
onMatchTap: _showWinnerDialog,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -63,9 +186,11 @@ class TournamentBracketPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BracketBoard extends StatelessWidget {
|
class _BracketBoard extends StatelessWidget {
|
||||||
final List<Team> teams;
|
final List<Match> matches;
|
||||||
|
final Map<int, Team> teamMap;
|
||||||
final int bracketSize;
|
final int bracketSize;
|
||||||
final int roundCount;
|
final int roundCount;
|
||||||
|
final Function(Match, Team, Team) onMatchTap;
|
||||||
|
|
||||||
static const double _cardWidth = 112;
|
static const double _cardWidth = 112;
|
||||||
static const double _cardHeight = 96;
|
static const double _cardHeight = 96;
|
||||||
@@ -75,9 +200,11 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
static const double _lineThickness = 2;
|
static const double _lineThickness = 2;
|
||||||
|
|
||||||
const _BracketBoard({
|
const _BracketBoard({
|
||||||
required this.teams,
|
required this.matches,
|
||||||
|
required this.teamMap,
|
||||||
required this.bracketSize,
|
required this.bracketSize,
|
||||||
required this.roundCount,
|
required this.roundCount,
|
||||||
|
required this.onMatchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
String _roundLabel(int round) {
|
String _roundLabel(int round) {
|
||||||
@@ -96,6 +223,16 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
double _cardCenterY(int round, int index) =>
|
double _cardCenterY(int round, int index) =>
|
||||||
_cardTop(round, index) + _cardHeight / 2;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -107,10 +244,12 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
|
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
|
|
||||||
|
// Build bracket
|
||||||
for (int round = 0; round < roundCount; round++) {
|
for (int round = 0; round < roundCount; round++) {
|
||||||
final cardsInRound = bracketSize ~/ (1 << round);
|
final cardsInRound = bracketSize ~/ (1 << round);
|
||||||
final left = round * (_cardWidth + _connectorWidth);
|
final left = round * (_cardWidth + _connectorWidth);
|
||||||
|
|
||||||
|
// Round label
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: left,
|
left: left,
|
||||||
@@ -131,24 +270,43 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Match cards
|
||||||
for (int i = 0; i < cardsInRound; i++) {
|
for (int i = 0; i < cardsInRound; i++) {
|
||||||
final String? label = (round == 0 && i < teams.length)
|
final match = _findMatch(round, i);
|
||||||
? teams[i].name
|
|
||||||
: null;
|
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: left,
|
left: left,
|
||||||
top: _cardTop(round, i),
|
top: _cardTop(round, i),
|
||||||
width: _cardWidth,
|
width: _cardWidth,
|
||||||
height: _cardHeight,
|
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) {
|
// Draw connectors
|
||||||
continue;
|
if (round == roundCount - 1) continue;
|
||||||
}
|
|
||||||
|
|
||||||
final connectorLeft = left + _cardWidth;
|
final connectorLeft = left + _cardWidth;
|
||||||
final matches = cardsInRound ~/ 2;
|
final matches = cardsInRound ~/ 2;
|
||||||
@@ -159,6 +317,7 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
final yBottom = _cardCenterY(round, i * 2 + 1);
|
final yBottom = _cardCenterY(round, i * 2 + 1);
|
||||||
final yMiddle = (yTop + yBottom) / 2;
|
final yMiddle = (yTop + yBottom) / 2;
|
||||||
|
|
||||||
|
// Top horizontal line
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: connectorLeft,
|
left: connectorLeft,
|
||||||
@@ -169,6 +328,7 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bottom horizontal line
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: connectorLeft,
|
left: connectorLeft,
|
||||||
@@ -179,6 +339,7 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: connectorLeft + halfConnector - _lineThickness / 2,
|
left: connectorLeft + halfConnector - _lineThickness / 2,
|
||||||
@@ -189,6 +350,7 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Middle horizontal line to next round
|
||||||
children.add(
|
children.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: connectorLeft + halfConnector,
|
left: connectorLeft + halfConnector,
|
||||||
@@ -209,36 +371,122 @@ class _BracketBoard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TeamCard extends StatelessWidget {
|
class _MatchCard extends StatelessWidget {
|
||||||
final String? label;
|
final Match? match;
|
||||||
|
final Map<int, Team> teamMap;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _TeamCard({this.label});
|
const _MatchCard({
|
||||||
|
this.match,
|
||||||
|
required this.teamMap,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isTbd = label == null;
|
|
||||||
|
|
||||||
return Card(
|
if (match == null) {
|
||||||
elevation: isTbd ? 1 : 2,
|
return Card(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
elevation: 1,
|
||||||
child: Center(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
child: Padding(
|
child: Center(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
isTbd ? '?' : label!,
|
'?',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: isTbd ? FontWeight.w400 : FontWeight.w600,
|
fontWeight: FontWeight.w400,
|
||||||
color: isTbd
|
color: colorScheme.onSurface.withValues(alpha: 0.38),
|
||||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
|
||||||
: colorScheme.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> 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<List<Match>> 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<dynamic> list = json.decode(response.body);
|
||||||
|
return list.map((json) => Match.fromJson(json)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user