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 {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);
|
||||
|
||||
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:
|
||||
- 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<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/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(),
|
||||
),
|
||||
|
||||
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: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<TournamentBracketPage> createState() => _TournamentBracketPageState();
|
||||
}
|
||||
|
||||
class _TournamentBracketPageState extends State<TournamentBracketPage> {
|
||||
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<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
|
||||
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(tournament.name)),
|
||||
body: FutureBuilder<List<Team>>(
|
||||
future: teamProvider.getTeamsByTournament(tournament.id),
|
||||
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());
|
||||
@@ -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<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(
|
||||
'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<Team> teams;
|
||||
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;
|
||||
@@ -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 = <Widget>[];
|
||||
|
||||
// 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<int, Team> 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;
|
||||
|
||||
if (match == null) {
|
||||
return Card(
|
||||
elevation: isTbd ? 1 : 2,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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