Adding match endpoints, services and providers, additionally added a page in the frontend that displays the data

This commit is contained in:
2026-03-12 15:05:13 +01:00
parent 1402c16f21
commit bc35ec9c01
8 changed files with 675 additions and 34 deletions

View File

@@ -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(),
),

View 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;
}

View File

@@ -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;
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,
),
);
}

View File

@@ -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();
}
}