diff --git a/backend_splatournament_manager/src/app.ts b/backend_splatournament_manager/src/app.ts index b6e4554..e861029 100644 --- a/backend_splatournament_manager/src/app.ts +++ b/backend_splatournament_manager/src/app.ts @@ -88,6 +88,9 @@ app.post('/teams', authMiddleware, async (req: Request, res: Response) => { } try { 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); } catch (err) { console.log(err); @@ -148,6 +151,60 @@ app.get('/teams/:id/tournaments', async (req: Request, res: Response) => { 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) => { const { username, password } = req.body; if (!username || !password) { diff --git a/backend_splatournament_manager/src/models/team.ts b/backend_splatournament_manager/src/models/team.ts index 44d3e17..a664fb6 100644 --- a/backend_splatournament_manager/src/models/team.ts +++ b/backend_splatournament_manager/src/models/team.ts @@ -13,3 +13,11 @@ export interface TournamentTeam { registeredAt: string; } +export interface TeamMember { + id: number; + teamId: number; + userId: number; + role: 'owner' | 'member'; + joinedAt: string; +} + diff --git a/backend_splatournament_manager/src/services/team-service.ts b/backend_splatournament_manager/src/services/team-service.ts index 8a988ba..83d4484 100644 --- a/backend_splatournament_manager/src/services/team-service.ts +++ b/backend_splatournament_manager/src/services/team-service.ts @@ -1,4 +1,4 @@ -import { Team, TournamentTeam } from '../models/team'; +import { Team, TournamentTeam, TeamMember } from '../models/team'; import { Database, RunResult } from 'sqlite3'; export class TeamService { @@ -26,6 +26,18 @@ export class TeamService { FOREIGN KEY (teamId) REFERENCES Teams (id) ON DELETE CASCADE, 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 { + return new Promise((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 { + 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 { + return new Promise((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 { + return new Promise((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 { + return new Promise((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); + } + ); + }); + } } diff --git a/backend_splatournament_manager/src/services/user-service.ts b/backend_splatournament_manager/src/services/user-service.ts index a9cde99..dcd7db3 100644 --- a/backend_splatournament_manager/src/services/user-service.ts +++ b/backend_splatournament_manager/src/services/user-service.ts @@ -90,4 +90,17 @@ export class UserService { ); }); } + + getUserByUsername(username: string): Promise { + 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); + } + ); + }); + } } diff --git a/docs/prompt.md b/docs/prompt.md new file mode 100644 index 0000000..b9c8191 --- /dev/null +++ b/docs/prompt.md @@ -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

+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) + +- \ No newline at end of file diff --git a/frontend_splatournament_manager/lib/pages/create_team_page.dart b/frontend_splatournament_manager/lib/pages/create_team_page.dart new file mode 100644 index 0000000..47e3f71 --- /dev/null +++ b/frontend_splatournament_manager/lib/pages/create_team_page.dart @@ -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 createState() => _CreateTeamPageState(); +} + +class _CreateTeamPageState extends State { + final _formKey = GlobalKey(); + 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(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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend_splatournament_manager/lib/pages/home_page.dart b/frontend_splatournament_manager/lib/pages/home_page.dart index 8c315af..f1a9c0a 100644 --- a/frontend_splatournament_manager/lib/pages/home_page.dart +++ b/frontend_splatournament_manager/lib/pages/home_page.dart @@ -1,59 +1,145 @@ import 'package:flutter/material.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/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_team_page.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State 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 Widget build(BuildContext context) { return Scaffold( 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: [ IconButton( onPressed: () async { - final tournamentProvider = Provider.of( - context, - listen: false, - ); try { - await tournamentProvider.refreshAvailableTournaments(); + if (_selectedIndex == 0) { + final tournamentProvider = Provider.of( + context, + listen: false, + ); + await tournamentProvider.refreshAvailableTournaments(); + } else { + final teamProvider = Provider.of( + context, + listen: false, + ); + await teamProvider.refreshTeams(); + } } catch (_) { if (!context.mounted) return; 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( onSelected: (value) { context.go("/settings"); }, - offset: Offset(0, 48), + offset: const Offset(0, 48), itemBuilder: (context) { - return [PopupMenuItem(value: 1, child: Text("Settings"))]; + return [const PopupMenuItem(value: 1, child: Text("Settings"))]; }, ), ], ), - body: Container( - padding: EdgeInsets.fromLTRB(0, 12, 0, 36), - child: Column(children: [Spacer(), AvailableTournamentList()]), + body: IndexedStack( + index: _selectedIndex, + 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( onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CreateTournamentPage(), - ), - ); + if (_selectedIndex == 0) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreateTournamentPage(), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CreateTeamPage(), + ), + ); + } }, child: const Icon(Icons.add), ), diff --git a/frontend_splatournament_manager/lib/pages/teams_page.dart b/frontend_splatournament_manager/lib/pages/teams_page.dart new file mode 100644 index 0000000..368b1cb --- /dev/null +++ b/frontend_splatournament_manager/lib/pages/teams_page.dart @@ -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( + 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( + builder: (context, provider, _) { + return FutureBuilder>( + 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( + 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), + ), + ], + ), + ), + ); + } +} diff --git a/frontend_splatournament_manager/lib/pages/tournament_detail_page.dart b/frontend_splatournament_manager/lib/pages/tournament_detail_page.dart index 310a5db..a8daad1 100644 --- a/frontend_splatournament_manager/lib/pages/tournament_detail_page.dart +++ b/frontend_splatournament_manager/lib/pages/tournament_detail_page.dart @@ -16,6 +16,90 @@ class TournamentDetailPage extends StatefulWidget { class _TournamentDetailPageState extends State { bool isShowingTeams = false; + void _showJoinTeamDialog(BuildContext context, int tournamentId) async { + final teamProvider = Provider.of(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( + 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 Widget build(BuildContext context) { return Scaffold( @@ -58,13 +142,9 @@ class _TournamentDetailPageState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), child: ElevatedButton( - child: Text("Enter"), + child: Text("Join with Team"), onPressed: () { - //TODO: Backend Call - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("tournament entered"))); + _showJoinTeamDialog(context, widget.tournament.id); }, ), ), diff --git a/frontend_splatournament_manager/lib/providers/team_provider.dart b/frontend_splatournament_manager/lib/providers/team_provider.dart index e0f5a4b..defc55a 100644 --- a/frontend_splatournament_manager/lib/providers/team_provider.dart +++ b/frontend_splatournament_manager/lib/providers/team_provider.dart @@ -6,6 +6,8 @@ class TeamProvider extends ChangeNotifier { final TeamService _teamService = TeamService(); List _teams = []; + Future>? _initialLoadFuture; + List get teams => _teams; Future> fetchAllTeams() async { @@ -14,6 +16,16 @@ class TeamProvider extends ChangeNotifier { return _teams; } + Future> ensureTeamsLoaded() { + _initialLoadFuture ??= fetchAllTeams(); + return _initialLoadFuture!; + } + + Future> refreshTeams() { + _initialLoadFuture = fetchAllTeams(); + return _initialLoadFuture!; + } + Future createTeam({ required String name, required String tag, @@ -36,7 +48,7 @@ class TeamProvider extends ChangeNotifier { String? description, }) async { await _teamService.updateTeam(id, name: name, tag: tag, description: description); - await fetchAllTeams(); + await refreshTeams(); } Future deleteTeam(int id) async { @@ -58,5 +70,23 @@ class TeamProvider extends ChangeNotifier { await _teamService.removeTeamFromTournament(tournamentId, teamId); notifyListeners(); } + + Future> getUserTeams() { + return _teamService.getUserTeams(); + } + + Future joinTeam(int teamId) async { + await _teamService.joinTeam(teamId); + notifyListeners(); + } + + Future leaveTeam(int teamId) async { + await _teamService.leaveTeam(teamId); + notifyListeners(); + } + + Future>> getTeamMembers(int teamId) { + return _teamService.getTeamMembers(teamId); + } } diff --git a/frontend_splatournament_manager/lib/services/team_service.dart b/frontend_splatournament_manager/lib/services/team_service.dart index 2c8dbdf..c72ce31 100644 --- a/frontend_splatournament_manager/lib/services/team_service.dart +++ b/frontend_splatournament_manager/lib/services/team_service.dart @@ -113,4 +113,41 @@ class TeamService { final List list = json.decode(response.body); return list.cast>(); } + + Future> 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 list = json.decode(response.body); + return list.map((j) => Team.fromJson(j as Map)).toList(); + } + + Future 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 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>> 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 list = json.decode(response.body); + return list.cast>(); + } } diff --git a/frontend_splatournament_manager/lib/widgets/my_teams_widget.dart b/frontend_splatournament_manager/lib/widgets/my_teams_widget.dart new file mode 100644 index 0000000..53e51a7 --- /dev/null +++ b/frontend_splatournament_manager/lib/widgets/my_teams_widget.dart @@ -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 createState() => _MyTeamsWidgetState(); +} + +class _MyTeamsWidgetState extends State { + late Future> _myTeamsFuture; + + @override + void initState() { + super.initState(); + _loadMyTeams(); + } + + void _loadMyTeams() { + _myTeamsFuture = Provider.of(context, listen: false).getUserTeams(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + 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 _leaveTeam(Team team) async { + final confirmed = await showDialog( + 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(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')), + ); + } + } + } + } +} diff --git a/frontend_splatournament_manager/lib/widgets/teams_list_widget.dart b/frontend_splatournament_manager/lib/widgets/teams_list_widget.dart new file mode 100644 index 0000000..8a6103b --- /dev/null +++ b/frontend_splatournament_manager/lib/widgets/teams_list_widget.dart @@ -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( + builder: (context, provider, _) { + return FutureBuilder>( + 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 _joinTeam(BuildContext context) async { + try { + await Provider.of(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 _deleteTeam(BuildContext context) async { + final confirmed = await showDialog( + 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(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')), + ); + } + } + } + } +}