Add team list and joining

This commit is contained in:
2026-03-11 18:06:22 +01:00
parent 216506070b
commit b86b71d29c
13 changed files with 1002 additions and 28 deletions

View File

@@ -88,6 +88,9 @@ app.post('/teams', authMiddleware, async (req: Request, res: Response) => {
} }
try { try {
const team = await teamService.addTeam({name, tag, description: description ?? ''}); const team = await teamService.addTeam({name, tag, description: description ?? ''});
// @ts-ignore
const userId = req.user.id;
await teamService.addTeamMember(team.id, userId, 'owner');
res.status(201).send(team); res.status(201).send(team);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -148,6 +151,60 @@ app.get('/teams/:id/tournaments', async (req: Request, res: Response) => {
res.send(entries); res.send(entries);
}); });
app.get('/users/me/teams', authMiddleware, async (req: Request, res: Response) => {
try {
// @ts-ignore
const userId = req.user.id;
const teams = await teamService.getTeamsByUserId(userId);
res.send(teams);
} catch (err) {
console.log(err);
res.status(400).send({error: 'Failed to get user teams'});
}
});
app.post('/teams/:id/members', authMiddleware, async (req: Request, res: Response) => {
try {
// @ts-ignore
const userId = req.user.id;
const teamId = +req.params.id;
const isInTeam = await teamService.isUserInTeam(teamId, userId);
if (isInTeam) {
return res.status(409).send({error: 'User is already a member of this team'});
}
const member = await teamService.addTeamMember(teamId, userId, 'member');
res.status(201).send(member);
} catch (err) {
console.log(err);
res.status(400).send({error: 'Failed to join team'});
}
});
app.delete('/teams/:id/members/me', authMiddleware, async (req: Request, res: Response) => {
try {
// @ts-ignore
const userId = req.user.id;
const teamId = +req.params.id;
await teamService.removeTeamMember(teamId, userId);
res.status(200).send({message: 'Left team successfully'});
} catch (err) {
console.log(err);
res.status(400).send({error: 'Failed to leave team'});
}
});
app.get('/teams/:id/members', async (req: Request, res: Response) => {
try {
const members = await teamService.getTeamMembers(+req.params.id);
res.send(members);
} catch (err) {
console.log(err);
res.status(400).send({error: 'Failed to get team members'});
}
});
app.post('/register', async (req: Request, res: Response) => { app.post('/register', async (req: Request, res: Response) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {

View File

@@ -13,3 +13,11 @@ export interface TournamentTeam {
registeredAt: string; registeredAt: string;
} }
export interface TeamMember {
id: number;
teamId: number;
userId: number;
role: 'owner' | 'member';
joinedAt: string;
}

View File

@@ -1,4 +1,4 @@
import { Team, TournamentTeam } from '../models/team'; import { Team, TournamentTeam, TeamMember } from '../models/team';
import { Database, RunResult } from 'sqlite3'; import { Database, RunResult } from 'sqlite3';
export class TeamService { export class TeamService {
@@ -26,6 +26,18 @@ export class TeamService {
FOREIGN KEY (teamId) REFERENCES Teams (id) ON DELETE CASCADE, FOREIGN KEY (teamId) REFERENCES Teams (id) ON DELETE CASCADE,
UNIQUE (tournamentId, teamId) UNIQUE (tournamentId, teamId)
)`); )`);
this.db.run(`CREATE TABLE IF NOT EXISTS TeamMembers
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
teamId INTEGER NOT NULL,
userId INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joinedAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (teamId) REFERENCES Teams (id) ON DELETE CASCADE,
FOREIGN KEY (userId) REFERENCES Users (id) ON DELETE CASCADE,
UNIQUE (teamId, userId)
)`);
}); });
} }
@@ -145,5 +157,78 @@ export class TeamService {
); );
}); });
} }
addTeamMember(teamId: number, userId: number, role: 'owner' | 'member' = 'member'): Promise<TeamMember> {
return new Promise<TeamMember>((resolve, reject) => {
const stmt = this.db.prepare(`INSERT INTO TeamMembers (teamId, userId, role) VALUES (?, ?, ?)`);
stmt.run(teamId, userId, role, function (this: RunResult, err: Error | null) {
if (err) return reject(err);
resolve({
id: (this as any).lastID,
teamId,
userId,
role,
joinedAt: new Date().toISOString(),
});
});
stmt.finalize();
});
}
removeTeamMember(teamId: number, userId: number): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(
`DELETE FROM TeamMembers WHERE teamId = ? AND userId = ?`,
[teamId, userId],
(err: Error | null) => {
if (err) return reject(err);
resolve();
}
);
});
}
getTeamsByUserId(userId: number): Promise<Team[]> {
return new Promise<Team[]>((resolve, reject) => {
this.db.all(
`SELECT t.*, tm.role, tm.joinedAt FROM Teams t
INNER JOIN TeamMembers tm ON t.id = tm.teamId
WHERE tm.userId = ?`,
[userId],
(err: Error | null, rows: Team[]) => {
if (err) return reject(err);
resolve(rows);
}
);
});
}
getTeamMembers(teamId: number): Promise<TeamMember[]> {
return new Promise<TeamMember[]>((resolve, reject) => {
this.db.all(
`SELECT tm.*, u.username FROM TeamMembers tm
INNER JOIN Users u ON tm.userId = u.id
WHERE tm.teamId = ?`,
[teamId],
(err: Error | null, rows: TeamMember[]) => {
if (err) return reject(err);
resolve(rows);
}
);
});
}
isUserInTeam(teamId: number, userId: number): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
this.db.get(
`SELECT COUNT(*) as count FROM TeamMembers WHERE teamId = ? AND userId = ?`,
[teamId, userId],
(err: Error | null, row: any) => {
if (err) return reject(err);
resolve(row.count > 0);
}
);
});
}
} }

View File

@@ -90,4 +90,17 @@ export class UserService {
); );
}); });
} }
getUserByUsername(username: string): Promise<User | undefined> {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username FROM Users WHERE username = ?',
[username],
(err: Error | null, user: User | undefined) => {
if (err) return reject(err);
resolve(user);
}
);
});
}
} }

22
docs/prompt.md Normal file
View File

@@ -0,0 +1,22 @@
# Prompts
Folgende Prompts wurden auf Englisch geschrieben.
## 11.03.2026
- the user should be able to join teams and then join a tournament as a team, also add that to the backend<br><br>
Folgende Dateien wurden in diesem Prompt verändert:
- team.ts
- team-service.ts
- user-service.ts
- app.ts
- team.dart (keine Änderungen, bereits vorhanden)
- team_service.dart
- team_provider.dart
- my_teams_widget.dart (neu erstellt)
- teams_list_widget.dart
- home_page.dart
- tournament_detail_page.dart
- teams_page.dart (erstellt, aber nicht verwendet)
-

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:frontend_splatournament_manager/models/team.dart';
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
import 'package:provider/provider.dart';
class CreateTeamPage extends StatefulWidget {
final Team? teamToEdit;
const CreateTeamPage({super.key, this.teamToEdit});
@override
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _tagController;
late final TextEditingController _descriptionController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.teamToEdit?.name ?? '');
_tagController = TextEditingController(text: widget.teamToEdit?.tag ?? '');
_descriptionController = TextEditingController(text: widget.teamToEdit?.description ?? '');
}
void _submitForm() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final provider = Provider.of<TeamProvider>(context, listen: false);
if (widget.teamToEdit != null) {
await provider.updateTeam(
widget.teamToEdit!.id,
name: _nameController.text,
tag: _tagController.text,
description: _descriptionController.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Team updated successfully')),
);
} else {
await provider.createTeam(
name: _nameController.text,
tag: _tagController.text,
description: _descriptionController.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Team created successfully')),
);
}
if (!context.mounted) return;
Navigator.pop(context);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
} finally {
if (context.mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_nameController.dispose();
_tagController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEditing = widget.teamToEdit != null;
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Edit Team' : 'Create Team'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Team Name',
hintText: 'Enter team name',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Team name is required';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _tagController,
decoration: const InputDecoration(
labelText: 'Team Tag',
hintText: 'Enter team tag (e.g., ABC)',
),
maxLength: 5,
textCapitalization: TextCapitalization.characters,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Team tag is required';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Enter team description (optional)',
),
maxLines: 3,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submitForm,
child: _isLoading
? const CircularProgressIndicator()
: Text(isEditing ? 'Update Team' : 'Create Team'),
),
],
),
),
),
);
}
}

View File

@@ -1,59 +1,145 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frontend_splatournament_manager/providers/tournament_provider.dart'; import 'package:frontend_splatournament_manager/providers/tournament_provider.dart';
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
import 'package:frontend_splatournament_manager/widgets/available_tournament_list.dart'; import 'package:frontend_splatournament_manager/widgets/available_tournament_list.dart';
import 'package:frontend_splatournament_manager/widgets/teams_list_widget.dart';
import 'package:frontend_splatournament_manager/widgets/my_teams_widget.dart';
import 'package:frontend_splatournament_manager/pages/create_tournament_page.dart'; import 'package:frontend_splatournament_manager/pages/create_tournament_page.dart';
import 'package:frontend_splatournament_manager/pages/create_team_page.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class HomePage extends StatelessWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
int _selectedIndex = 0;
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Splatournament"), title: Text(_selectedIndex == 0 ? "Tournaments" : "Teams"),
bottom: _selectedIndex == 1
? TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'All Teams'),
Tab(text: 'My Teams'),
],
)
: null,
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () async {
final tournamentProvider = Provider.of<TournamentProvider>(
context,
listen: false,
);
try { try {
await tournamentProvider.refreshAvailableTournaments(); if (_selectedIndex == 0) {
final tournamentProvider = Provider.of<TournamentProvider>(
context,
listen: false,
);
await tournamentProvider.refreshAvailableTournaments();
} else {
final teamProvider = Provider.of<TeamProvider>(
context,
listen: false,
);
await teamProvider.refreshTeams();
}
} catch (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to refresh tournaments')), SnackBar(
content: Text(
'Failed to refresh ${_selectedIndex == 0 ? "tournaments" : "teams"}',
),
),
); );
} }
}, },
icon: Icon(Icons.refresh), icon: const Icon(Icons.refresh),
), ),
PopupMenuButton( PopupMenuButton(
onSelected: (value) { onSelected: (value) {
context.go("/settings"); context.go("/settings");
}, },
offset: Offset(0, 48), offset: const Offset(0, 48),
itemBuilder: (context) { itemBuilder: (context) {
return [PopupMenuItem(value: 1, child: Text("Settings"))]; return [const PopupMenuItem(value: 1, child: Text("Settings"))];
}, },
), ),
], ],
), ),
body: Container( body: IndexedStack(
padding: EdgeInsets.fromLTRB(0, 12, 0, 36), index: _selectedIndex,
child: Column(children: [Spacer(), AvailableTournamentList()]), children: [
// Tournaments View
Container(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 36),
child: Column(children: [const Spacer(), const AvailableTournamentList()]),
),
// Teams View with tabs
TabBarView(
controller: _tabController,
children: const [
TeamsListWidget(),
MyTeamsWidget(),
],
),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.emoji_events),
label: 'Tournaments',
),
BottomNavigationBarItem(
icon: Icon(Icons.groups),
label: 'Teams',
),
],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () {
Navigator.push( if (_selectedIndex == 0) {
context, Navigator.push(
MaterialPageRoute( context,
builder: (context) => const CreateTournamentPage(), MaterialPageRoute(
), builder: (context) => const CreateTournamentPage(),
); ),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreateTeamPage(),
),
);
}
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:frontend_splatournament_manager/models/team.dart';
import 'package:frontend_splatournament_manager/pages/create_team_page.dart';
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
import 'package:provider/provider.dart';
class TeamsPage extends StatelessWidget {
const TeamsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Teams"),
actions: [
IconButton(
onPressed: () async {
final teamProvider = Provider.of<TeamProvider>(
context,
listen: false,
);
try {
await teamProvider.refreshTeams();
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to refresh teams')),
);
}
},
icon: const Icon(Icons.refresh),
),
],
),
body: Container(
padding: const EdgeInsets.all(16),
child: Consumer<TeamProvider>(
builder: (context, provider, _) {
return FutureBuilder<List<Team>>(
future: provider.ensureTeamsLoaded(),
builder: (context, snapshot) {
final teams = provider.teams;
if (snapshot.connectionState == ConnectionState.waiting &&
teams.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && teams.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (teams.isEmpty) {
return const Center(child: Text('No teams found'));
}
return ListView.builder(
itemCount: teams.length,
itemBuilder: (context, index) {
final team = teams[index];
return TeamListItem(team: team);
},
);
},
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreateTeamPage(),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
class TeamListItem extends StatelessWidget {
final Team team;
const TeamListItem({super.key, required this.team});
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Delete Team'),
content: Text('Are you sure you want to delete "${team.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
try {
final provider = Provider.of<TeamProvider>(
context,
listen: false,
);
await provider.deleteTeam(team.id);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Team deleted')),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
child: Text(team.tag),
),
title: Text(team.name),
subtitle: Text(team.description.isEmpty ? 'No description' : team.description),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CreateTeamPage(teamToEdit: team),
),
);
},
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _showDeleteDialog(context),
),
],
),
),
);
}
}

View File

@@ -16,6 +16,90 @@ class TournamentDetailPage extends StatefulWidget {
class _TournamentDetailPageState extends State<TournamentDetailPage> { class _TournamentDetailPageState extends State<TournamentDetailPage> {
bool isShowingTeams = false; bool isShowingTeams = false;
void _showJoinTeamDialog(BuildContext context, int tournamentId) async {
final teamProvider = Provider.of<TeamProvider>(context, listen: false);
try {
final teams = await teamProvider.getUserTeams();
if (!context.mounted) return;
if (teams.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('You are not a member of any team. Join or create a team first!')),
);
return;
}
final selectedTeam = await showDialog<Team>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Select Your Team'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: teams.length,
itemBuilder: (context, index) {
final team = teams[index];
return ListTile(
leading: CircleAvatar(child: Text(team.tag)),
title: Text(team.name),
subtitle: team.description.isNotEmpty
? Text(team.description)
: null,
onTap: () => Navigator.pop(dialogContext, team),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
],
);
},
);
if (selectedTeam != null && context.mounted) {
try {
await teamProvider.registerTeamForTournament(
tournamentId,
selectedTeam.id,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${selectedTeam.name} joined the tournament!'),
),
);
// Refresh teams list if currently showing
if (isShowingTeams) {
setState(() {});
}
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to join: ${e.toString().replaceAll('Exception: ', '')}'),
),
);
}
}
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load your teams: $e')),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -58,13 +142,9 @@ class _TournamentDetailPageState extends State<TournamentDetailPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
child: ElevatedButton( child: ElevatedButton(
child: Text("Enter"), child: Text("Join with Team"),
onPressed: () { onPressed: () {
//TODO: Backend Call _showJoinTeamDialog(context, widget.tournament.id);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("tournament entered")));
}, },
), ),
), ),

View File

@@ -6,6 +6,8 @@ class TeamProvider extends ChangeNotifier {
final TeamService _teamService = TeamService(); final TeamService _teamService = TeamService();
List<Team> _teams = []; List<Team> _teams = [];
Future<List<Team>>? _initialLoadFuture;
List<Team> get teams => _teams; List<Team> get teams => _teams;
Future<List<Team>> fetchAllTeams() async { Future<List<Team>> fetchAllTeams() async {
@@ -14,6 +16,16 @@ class TeamProvider extends ChangeNotifier {
return _teams; return _teams;
} }
Future<List<Team>> ensureTeamsLoaded() {
_initialLoadFuture ??= fetchAllTeams();
return _initialLoadFuture!;
}
Future<List<Team>> refreshTeams() {
_initialLoadFuture = fetchAllTeams();
return _initialLoadFuture!;
}
Future<Team> createTeam({ Future<Team> createTeam({
required String name, required String name,
required String tag, required String tag,
@@ -36,7 +48,7 @@ class TeamProvider extends ChangeNotifier {
String? description, String? description,
}) async { }) async {
await _teamService.updateTeam(id, name: name, tag: tag, description: description); await _teamService.updateTeam(id, name: name, tag: tag, description: description);
await fetchAllTeams(); await refreshTeams();
} }
Future<void> deleteTeam(int id) async { Future<void> deleteTeam(int id) async {
@@ -58,5 +70,23 @@ class TeamProvider extends ChangeNotifier {
await _teamService.removeTeamFromTournament(tournamentId, teamId); await _teamService.removeTeamFromTournament(tournamentId, teamId);
notifyListeners(); notifyListeners();
} }
Future<List<Team>> getUserTeams() {
return _teamService.getUserTeams();
}
Future<void> joinTeam(int teamId) async {
await _teamService.joinTeam(teamId);
notifyListeners();
}
Future<void> leaveTeam(int teamId) async {
await _teamService.leaveTeam(teamId);
notifyListeners();
}
Future<List<Map<String, dynamic>>> getTeamMembers(int teamId) {
return _teamService.getTeamMembers(teamId);
}
} }

View File

@@ -113,4 +113,41 @@ class TeamService {
final List<dynamic> list = json.decode(response.body); final List<dynamic> list = json.decode(response.body);
return list.cast<Map<String, dynamic>>(); return list.cast<Map<String, dynamic>>();
} }
Future<List<Team>> getUserTeams() async {
final response = await ApiClient.get('/users/me/teams');
if (response.statusCode != HttpStatus.ok) {
throw Exception('Failed to load user teams (${response.statusCode})');
}
final List<dynamic> list = json.decode(response.body);
return list.map((j) => Team.fromJson(j as Map<String, dynamic>)).toList();
}
Future<void> joinTeam(int teamId) async {
final response = await ApiClient.post('/teams/$teamId/members', {});
if (response.statusCode == 409) {
throw Exception('You are already a member of this team');
}
if (response.statusCode != HttpStatus.created) {
final body = json.decode(response.body);
throw Exception(body['error'] ?? 'Failed to join team');
}
}
Future<void> leaveTeam(int teamId) async {
final response = await ApiClient.delete('/teams/$teamId/members/me');
if (response.statusCode != HttpStatus.ok) {
final body = json.decode(response.body);
throw Exception(body['error'] ?? 'Failed to leave team');
}
}
Future<List<Map<String, dynamic>>> getTeamMembers(int teamId) async {
final response = await ApiClient.get('/teams/$teamId/members');
if (response.statusCode != HttpStatus.ok) {
throw Exception('Failed to load team members (${response.statusCode})');
}
final List<dynamic> list = json.decode(response.body);
return list.cast<Map<String, dynamic>>();
}
} }

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:frontend_splatournament_manager/models/team.dart';
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
import 'package:provider/provider.dart';
class MyTeamsWidget extends StatefulWidget {
const MyTeamsWidget({super.key});
@override
State<MyTeamsWidget> createState() => _MyTeamsWidgetState();
}
class _MyTeamsWidgetState extends State<MyTeamsWidget> {
late Future<List<Team>> _myTeamsFuture;
@override
void initState() {
super.initState();
_loadMyTeams();
}
void _loadMyTeams() {
_myTeamsFuture = Provider.of<TeamProvider>(context, listen: false).getUserTeams();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Team>>(
future: _myTeamsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final teams = snapshot.data ?? [];
if (teams.isEmpty) {
return const Center(
child: Text('You are not in any teams yet\nJoin teams from the All Teams tab'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: teams.length,
itemBuilder: (context, index) => _buildTeamCard(teams[index]),
);
},
);
}
Widget _buildTeamCard(Team team) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(child: Text(team.tag)),
title: Text(team.name),
subtitle: Text(team.description.isEmpty ? 'No description' : team.description),
trailing: IconButton(
icon: const Icon(Icons.logout, color: Colors.red),
onPressed: () => _leaveTeam(team),
),
),
);
}
Future<void> _leaveTeam(Team team) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave Team?'),
content: Text('Leave "${team.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Leave', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && mounted) {
try {
await Provider.of<TeamProvider>(context, listen: false).leaveTeam(team.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Left team')),
);
_loadMyTeams();
setState(() {});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:frontend_splatournament_manager/models/team.dart';
import 'package:frontend_splatournament_manager/pages/create_team_page.dart';
import 'package:frontend_splatournament_manager/providers/team_provider.dart';
import 'package:provider/provider.dart';
class TeamsListWidget extends StatelessWidget {
const TeamsListWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Consumer<TeamProvider>(
builder: (context, provider, _) {
return FutureBuilder<List<Team>>(
future: provider.ensureTeamsLoaded(),
builder: (context, snapshot) {
final teams = provider.teams;
if (snapshot.connectionState == ConnectionState.waiting && teams.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && teams.isEmpty) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (teams.isEmpty) {
return const Center(child: Text('No teams found'));
}
return ListView.builder(
itemCount: teams.length,
itemBuilder: (context, index) => TeamListItem(team: teams[index]),
);
},
);
},
),
);
}
}
class TeamListItem extends StatelessWidget {
final Team team;
const TeamListItem({super.key, required this.team});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(child: Text(team.tag)),
title: Text(team.name),
subtitle: Text(team.description.isEmpty ? 'No description' : team.description),
trailing: PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
const PopupMenuItem(value: 'join', child: Text('Join Team')),
const PopupMenuItem(value: 'edit', child: Text('Edit Team')),
const PopupMenuItem(
value: 'delete',
child: Text('Delete Team', style: TextStyle(color: Colors.red)),
),
],
onSelected: (value) async {
switch (value) {
case 'join':
await _joinTeam(context);
case 'edit':
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CreateTeamPage(teamToEdit: team),
),
);
case 'delete':
await _deleteTeam(context);
}
},
),
),
);
}
Future<void> _joinTeam(BuildContext context) async {
try {
await Provider.of<TeamProvider>(context, listen: false).joinTeam(team.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Joined ${team.name}!')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
}
}
}
Future<void> _deleteTeam(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Team?'),
content: Text('Delete "${team.name}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
await Provider.of<TeamProvider>(context, listen: false).deleteTeam(team.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Team deleted')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
}
}