feat: implemented team organsation
This commit is contained in:
@@ -12,7 +12,7 @@ import 'package:tallee/data/models/player.dart';
|
||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_game_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/create_match/choose_group_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/create_match/organize_teams_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/create_match/team_match/organize_teams_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart';
|
||||
import 'package:tallee/presentation/widgets/player_selection.dart';
|
||||
@@ -296,7 +296,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
|
||||
|
||||
if (isTeamMatch) {
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacement(
|
||||
Navigator.push(
|
||||
context,
|
||||
adaptivePageRoute(
|
||||
fullscreenDialog: !isTeamMatch,
|
||||
@@ -385,7 +385,9 @@ class _CreateMatchViewState extends State<CreateMatchView> {
|
||||
isTeamMatch: isTeamMatch,
|
||||
game: selectedGame!,
|
||||
);
|
||||
await db.matchDao.addMatch(match: match);
|
||||
|
||||
// Team matches are saved in OrganizeTeamsView
|
||||
if (!isTeamMatch) await db.matchDao.addMatch(match: match);
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tallee/core/adaptive_page_route.dart';
|
||||
import 'package:tallee/core/common.dart';
|
||||
import 'package:tallee/core/custom_theme.dart';
|
||||
import 'package:tallee/core/enums.dart';
|
||||
import 'package:tallee/data/db/database.dart';
|
||||
import 'package:tallee/data/models/match.dart';
|
||||
import 'package:tallee/data/models/player.dart';
|
||||
import 'package:tallee/data/models/team.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/team_creation_tile.dart';
|
||||
|
||||
class OrganizeTeamsView extends StatefulWidget {
|
||||
const OrganizeTeamsView({super.key, required this.match});
|
||||
|
||||
final Match match;
|
||||
|
||||
@override
|
||||
State<OrganizeTeamsView> createState() => _OrganizeTeamsViewState();
|
||||
}
|
||||
|
||||
class _OrganizeTeamsViewState extends State<OrganizeTeamsView> {
|
||||
final Random _random = Random();
|
||||
late final List<_TeamDraft> _teams;
|
||||
|
||||
List<Player> get _players => widget.match.players;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_teams = List.generate(2, _createTeamDraft);
|
||||
_redistributePlayers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final team in _teams) {
|
||||
team.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: CustomTheme.backgroundColor,
|
||||
appBar: AppBar(title: const Text('Organize Teams')),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 12),
|
||||
itemCount: _teams.length,
|
||||
itemBuilder: (context, index) {
|
||||
return TeamCreationTile(
|
||||
color: _teams[index].color,
|
||||
controller: _teams[index].nameController,
|
||||
players: _teams[index].members,
|
||||
hintText: 'Team ${index + 1}',
|
||||
onDelete: () => _removeTeam(index),
|
||||
onColorSelection: (color) {
|
||||
setState(() {
|
||||
_teams[index].color = color;
|
||||
});
|
||||
},
|
||||
onPlayerTap: (player) =>
|
||||
_showMovePlayerSheet(player, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
MainMenuButton(
|
||||
icon: Icons.cached,
|
||||
onPressed: () => setState(() {
|
||||
_redistributePlayers();
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
MainMenuButton(
|
||||
text: 'Add team',
|
||||
icon: Icons.emoji_events,
|
||||
onPressed: _teams.length >= widget.match.players.length
|
||||
? null
|
||||
: _addTeam,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
MainMenuButton(
|
||||
icon: Icons.check,
|
||||
onPressed: () async {
|
||||
final match = await createMatchWithTeams();
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
adaptivePageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => MatchResultView(match: match),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Match> createMatchWithTeams() async {
|
||||
final teams = _teams
|
||||
.map(
|
||||
(team) => Team(
|
||||
name: team.nameController.text.trim().isNotEmpty
|
||||
? team.nameController.text.trim()
|
||||
: 'Team ${_teams.indexOf(team) + 1}',
|
||||
color: team.color,
|
||||
members: team.members,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final db = Provider.of<AppDatabase>(context, listen: false);
|
||||
await db.teamDao.addTeamsAsList(teams: teams, matchId: widget.match.id);
|
||||
return widget.match.copyWith(teams: teams);
|
||||
}
|
||||
|
||||
_TeamDraft _createTeamDraft(int index) {
|
||||
return _TeamDraft(
|
||||
nameController: TextEditingController(text: 'Team ${index + 1}'),
|
||||
color: getTeamColor(index),
|
||||
);
|
||||
}
|
||||
|
||||
void _addTeam() {
|
||||
setState(() {
|
||||
_teams.add(_createTeamDraft(_teams.length));
|
||||
_redistributePlayers();
|
||||
});
|
||||
}
|
||||
|
||||
void _removeTeam(int index) {
|
||||
setState(() {
|
||||
final removedTeam = _teams.removeAt(index);
|
||||
removedTeam.dispose();
|
||||
|
||||
if (_teams.isEmpty) {
|
||||
_teams.add(_createTeamDraft(0));
|
||||
}
|
||||
|
||||
_redistributePlayers();
|
||||
});
|
||||
}
|
||||
|
||||
void _movePlayer(Player player, int fromTeamIndex, int toTeamIndex) {
|
||||
setState(() {
|
||||
_teams[fromTeamIndex].members.remove(player);
|
||||
_teams[toTeamIndex].members.add(player);
|
||||
});
|
||||
}
|
||||
|
||||
void _showMovePlayerSheet(Player player, int fromTeamIndex) {
|
||||
final otherTeams = [
|
||||
for (int i = 0; i < _teams.length; i++)
|
||||
if (i != fromTeamIndex) (index: i, team: _teams[i]),
|
||||
];
|
||||
|
||||
if (otherTeams.isEmpty) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: CustomTheme.backgroundColor,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'${player.name} verschieben in …',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: CustomTheme.textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
...otherTeams.map((entry) {
|
||||
final teamName =
|
||||
entry.team.nameController.text.trim().isNotEmpty
|
||||
? entry.team.nameController.text.trim()
|
||||
: 'Team ${entry.index + 1}';
|
||||
final teamColor = getColorFromGameColor(entry.team.color);
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: teamColor,
|
||||
),
|
||||
title: Text(
|
||||
teamName,
|
||||
style: const TextStyle(color: CustomTheme.textColor),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_movePlayer(player, fromTeamIndex, entry.index);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _redistributePlayers() {
|
||||
for (final team in _teams) {
|
||||
team.members.clear();
|
||||
}
|
||||
|
||||
if (_players.isEmpty || _teams.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final shuffledPlayers = [..._players]..shuffle(_random);
|
||||
|
||||
for (int i = 0; i < shuffledPlayers.length; i++) {
|
||||
final teamIndex = i % _teams.length;
|
||||
_teams[teamIndex].members.add(shuffledPlayers[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamDraft {
|
||||
_TeamDraft({required this.nameController, required this.color});
|
||||
|
||||
final TextEditingController nameController;
|
||||
GameColor color;
|
||||
final List<Player> members = [];
|
||||
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tallee/data/models/player.dart';
|
||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart';
|
||||
import 'package:tallee/presentation/widgets/player_selection.dart';
|
||||
|
||||
class EditMembersView extends StatefulWidget {
|
||||
const EditMembersView({
|
||||
super.key,
|
||||
required this.matchPlayer,
|
||||
required this.teamMember,
|
||||
});
|
||||
|
||||
final List<Player> matchPlayer;
|
||||
|
||||
final List<Player> teamMember;
|
||||
|
||||
@override
|
||||
State<EditMembersView> createState() => _EditMembersViewState();
|
||||
}
|
||||
|
||||
class _EditMembersViewState extends State<EditMembersView> {
|
||||
List<Player> selectedPlayers = [];
|
||||
List<Player> matchPlayer = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
selectedPlayers = [...widget.teamMember];
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(loc.edit_members),
|
||||
leading: HapticIconButton(
|
||||
onPressed: selectedPlayers.isNotEmpty
|
||||
? () => Navigator.pop(context, selectedPlayers)
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_back_ios_new_outlined),
|
||||
),
|
||||
),
|
||||
body: PlayerSelection(
|
||||
initialSelectedPlayers: widget.teamMember,
|
||||
availablePlayers: widget.matchPlayer,
|
||||
onChanged: (List<Player> newSelectedPlayers) {
|
||||
setState(() {
|
||||
selectedPlayers = newSelectedPlayers;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tallee/core/adaptive_page_route.dart';
|
||||
import 'package:tallee/core/common.dart';
|
||||
import 'package:tallee/core/custom_theme.dart';
|
||||
import 'package:tallee/data/db/database.dart';
|
||||
import 'package:tallee/data/models/match.dart';
|
||||
import 'package:tallee/data/models/player.dart';
|
||||
import 'package:tallee/data/models/team.dart';
|
||||
import 'package:tallee/l10n/generated/app_localizations.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/create_match/team_match/edit_members_view.dart';
|
||||
import 'package:tallee/presentation/views/main_menu/match_view/match_result_view.dart';
|
||||
import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart';
|
||||
import 'package:tallee/presentation/widgets/tiles/team_creation_tile.dart';
|
||||
|
||||
class OrganizeTeamsView extends StatefulWidget {
|
||||
const OrganizeTeamsView({super.key, required this.match});
|
||||
|
||||
final Match match;
|
||||
|
||||
@override
|
||||
State<OrganizeTeamsView> createState() => _OrganizeTeamsViewState();
|
||||
}
|
||||
|
||||
class _OrganizeTeamsViewState extends State<OrganizeTeamsView> {
|
||||
final Random random = Random();
|
||||
late List<Team> teams;
|
||||
late List<TextEditingController> nameController;
|
||||
|
||||
final int initialTeamCount = 2;
|
||||
List<Player> get matchPlayers => widget.match.players;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final loc = AppLocalizations.of(context);
|
||||
|
||||
// Init the teams
|
||||
teams = List.generate(
|
||||
initialTeamCount,
|
||||
(index) => Team(
|
||||
name: '${loc.team} ${index + 1}',
|
||||
color: getTeamColor(index),
|
||||
members: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Init the controllers
|
||||
nameController = teams.map(getNewController).toList();
|
||||
redistributePlayers();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: CustomTheme.backgroundColor,
|
||||
appBar: AppBar(title: Text(loc.create_teams)),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 12),
|
||||
itemCount: teams.length,
|
||||
itemBuilder: (context, index) {
|
||||
return TeamCreationTile(
|
||||
color: teams[index].color,
|
||||
controller: nameController[index],
|
||||
players: teams[index].members,
|
||||
hintText: '${loc.team} ${index + 1}',
|
||||
onEdit: () async {
|
||||
final newPlayers = await Navigator.push(
|
||||
context,
|
||||
adaptivePageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => EditMembersView(
|
||||
matchPlayer: widget.match.players,
|
||||
teamMember: teams[index].members,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
// Remove the selected players from every team
|
||||
for (final player in newPlayers) {
|
||||
for (final team in teams) {
|
||||
if (team.members.contains(player)) {
|
||||
team.members.remove(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the selected players to the current team
|
||||
teams[index] = teams[index].copyWith(
|
||||
members: newPlayers,
|
||||
);
|
||||
});
|
||||
},
|
||||
onDelete: teams.length >= 3
|
||||
? () => _removeTeam(index)
|
||||
: null,
|
||||
onColorSelection: (color) {
|
||||
setState(() {
|
||||
teams[index] = teams[index].copyWith(color: color);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
MainMenuButton(
|
||||
icon: Icons.cached,
|
||||
text: loc.redistribute,
|
||||
onPressed: () => setState(() {
|
||||
redistributePlayers();
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
MainMenuButton(
|
||||
icon: Icons.add,
|
||||
onPressed: teams.length >= widget.match.players.length
|
||||
? null
|
||||
: addTeam,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
MainMenuButton(
|
||||
icon: Icons.check,
|
||||
onPressed: teams.every((team) => team.members.isNotEmpty)
|
||||
? () async {
|
||||
final match = await createMatchWithTeams();
|
||||
if (context.mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
adaptivePageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) =>
|
||||
MatchResultView(match: match),
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a new team to the list of teams, creates a corresponding controller,
|
||||
/// and redistributes the players among all teams.
|
||||
void addTeam() {
|
||||
setState(() {
|
||||
final newTeam = getNewTeam();
|
||||
teams.add(newTeam);
|
||||
nameController.add(getNewController(newTeam));
|
||||
redistributePlayers();
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a new team with a default name and color based on the current number
|
||||
Team getNewTeam() {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return Team(
|
||||
name: '${loc.team} ${teams.length + 1}',
|
||||
color: getTeamColor(teams.length),
|
||||
members: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a [TextEditingController] for the given team and sets up a listener
|
||||
/// to update the team's name whenever the text changes.
|
||||
TextEditingController getNewController(Team team) {
|
||||
final textController = TextEditingController(text: team.name);
|
||||
textController.addListener(() {
|
||||
final index = teams.indexWhere((t) => t.id == team.id);
|
||||
if (index == -1) return;
|
||||
teams[index] = teams[index].copyWith(name: textController.text);
|
||||
});
|
||||
return textController;
|
||||
}
|
||||
|
||||
/// Removes the team with the given index and redistributes its players to the
|
||||
/// remaining teams. If there are less than 2 teams the removed team gets
|
||||
/// replaced with a new one
|
||||
void _removeTeam(int index) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
|
||||
setState(() {
|
||||
final removedTeam = teams.removeAt(index);
|
||||
final removedController = nameController.removeAt(index);
|
||||
removedController.dispose();
|
||||
if (teams.length < 2) {
|
||||
final fallbackTeam = getNewTeam();
|
||||
teams.add(fallbackTeam);
|
||||
nameController.add(getNewController(fallbackTeam));
|
||||
}
|
||||
|
||||
// Update index-based team names
|
||||
for (int i = 0; i < nameController.length; i++) {
|
||||
if (nameController[i].text.contains(
|
||||
RegExp('^${RegExp.escape(loc.team)} \\d+\$'),
|
||||
)) {
|
||||
nameController[i].text = '${loc.team} ${i + 1}';
|
||||
}
|
||||
}
|
||||
|
||||
addToSmallestTeams(removedTeam.members);
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds the given players to the teams with the least amount of members
|
||||
/// [orphanedPlayers] The players to be added to the teams.
|
||||
void addToSmallestTeams(List<Player> orphanedPlayers) {
|
||||
if (teams.isEmpty || orphanedPlayers.isEmpty) return;
|
||||
|
||||
for (final player in orphanedPlayers) {
|
||||
var targetIndex = 0;
|
||||
for (var i = 1; i < teams.length; i++) {
|
||||
if (teams[i].members.length < teams[targetIndex].members.length) {
|
||||
targetIndex = i;
|
||||
}
|
||||
}
|
||||
teams[targetIndex].members.add(player);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterates through all teams and redistributes players randomly and
|
||||
// as evenly as possible.
|
||||
void redistributePlayers() {
|
||||
for (final team in teams) {
|
||||
team.members.clear();
|
||||
}
|
||||
|
||||
if (matchPlayers.isEmpty || teams.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final shuffledPlayers = [...matchPlayers]..shuffle(random);
|
||||
|
||||
for (int i = 0; i < shuffledPlayers.length; i++) {
|
||||
final teamIndex = i % teams.length;
|
||||
teams[teamIndex].members.add(shuffledPlayers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the teams to the database and returns the updated match with the teams.
|
||||
Future<Match> createMatchWithTeams() async {
|
||||
final db = Provider.of<AppDatabase>(context, listen: false);
|
||||
final match = widget.match.copyWith(teams: teams);
|
||||
await db.matchDao.addMatch(match: match);
|
||||
return match;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in nameController) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,7 @@ class _MatchResultViewState extends State<MatchResultView> {
|
||||
} else {
|
||||
return await db.teamDao.setWinnerTeams(
|
||||
matchId: widget.match.id,
|
||||
|
||||
winners: _selectedTeams.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ class _MatchViewState extends State<MatchView> {
|
||||
|
||||
/// Loads the matches from the database and sorts them by creation date.
|
||||
void loadMatches() {
|
||||
print('Loading matches from database');
|
||||
isLoading = true;
|
||||
Future.wait([
|
||||
db.matchDao.getAllMatches(),
|
||||
|
||||
Reference in New Issue
Block a user