diff --git a/lib/data/dao/group_dao.dart b/lib/data/dao/group_dao.dart index bffe5a4..2de2ab9 100644 --- a/lib/data/dao/group_dao.dart +++ b/lib/data/dao/group_dao.dart @@ -185,6 +185,38 @@ class GroupDao extends DatabaseAccessor with _$GroupDaoMixin { return count ?? 0; } + /// Retrieves all groups a specific player belongs to. + /// Returns an empty list if the player is not part of any group. + Future> getGroupsByPlayer({required String playerId}) async { + final playerGroups = await (select( + playerGroupTable, + )..where((pg) => pg.playerId.equals(playerId))).get(); + + if (playerGroups.isEmpty) return []; + + final groupIds = playerGroups.map((pg) => pg.groupId).toSet().toList(); + final rows = + await (select(groupTable) + ..where((g) => g.id.isIn(groupIds)) + ..orderBy([(g) => OrderingTerm.desc(g.createdAt)])) + .get(); + + return Future.wait( + rows.map((groupData) async { + final members = await db.playerGroupDao.getPlayersOfGroup( + groupId: groupData.id, + ); + return Group( + id: groupData.id, + name: groupData.name, + description: groupData.description, + members: members, + createdAt: groupData.createdAt, + ); + }), + ); + } + /// Checks if a group with the given [groupId] exists in the database. /// Returns `true` if the group exists, `false` otherwise. Future groupExists({required String groupId}) async { diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 88cca35..e8414f4 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -352,6 +352,53 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { return count ?? 0; } + Future> getMatchesByPlayer({required String playerId}) async { + final playerMatches = await (select( + playerMatchTable, + )..where((pm) => pm.playerId.equals(playerId))).get(); + + if (playerMatches.isEmpty) return []; + + final matchIds = playerMatches.map((pm) => pm.matchId).toSet().toList(); + final rows = + await (select(matchTable) + ..where((m) => m.id.isIn(matchIds)) + ..orderBy([(m) => OrderingTerm.desc(m.createdAt)])) + .get(); + + return Future.wait( + rows.map((row) async { + final game = await db.gameDao.getGameById(gameId: row.gameId); + + Group? group; + if (row.groupId != null) { + group = await db.groupDao.getGroupById(groupId: row.groupId!); + } + + final players = await db.playerMatchDao.getPlayersOfMatch( + matchId: row.id, + ); + final scores = await db.scoreEntryDao.getAllMatchScores( + matchId: row.id, + ); + final teams = await _getMatchTeams(matchId: row.id); + + return Match( + id: row.id, + name: row.name, + game: game, + group: group, + players: players, + teams: teams.isEmpty ? null : teams, + notes: row.notes, + createdAt: row.createdAt, + endedAt: row.endedAt, + scores: scores, + ); + }), + ); + } + /// Retrieves all matches associated with the given [groupId]. /// Queries the database directly, filtering by [groupId]. Future> getMatchesByGroup({required String groupId}) async { diff --git a/lib/data/dao/player_dao.dart b/lib/data/dao/player_dao.dart index 51e5845..a6fd1c5 100644 --- a/lib/data/dao/player_dao.dart +++ b/lib/data/dao/player_dao.dart @@ -17,7 +17,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { /// the new one. Future addPlayer({required Player player}) async { if (!await playerExists(playerId: player.id)) { - final int nameCount = await calculateNameCount(name: player.name); + final int nameCount = await _processNameCount(name: player.name); await into(playerTable).insert( PlayerTableCompanion.insert( @@ -64,7 +64,7 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { final playersWithName = entry.value; // Get the current nameCount - var nameCount = await calculateNameCount(name: name); + var nameCount = await _processNameCount(name: name); // One player with the same name if (playersWithName.length == 1) { @@ -159,44 +159,63 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { /* Update */ /// Updates the name of the player with the given [playerId] to [name]. + /// + /// Keeps the `nameCount` values of the affected name groups consistent: + /// - The renamed player gets a fresh `nameCount` for the new name group. + /// - All players in the previous name group whose `nameCount` was greater + /// than the removed one get decremented by 1, so the numbering stays + /// contiguous (1..N) in `createdAt` order. + /// - If only one player remains in the previous name group, their + /// `nameCount` is reset to 0. Future updatePlayerName({ required String playerId, required String name, }) async { - // Get previous name and name count for the player before updating - final previousPlayerName = - await (select(playerTable)..where((p) => p.id.equals(playerId))) - .map((row) => row.name) - .getSingleOrNull() ?? - ''; - final previousNameCount = await getNameCount(name: previousPlayerName); + return transaction(() async { + final previousPlayer = await (select( + playerTable, + )..where((p) => p.id.equals(playerId))).getSingleOrNull(); + if (previousPlayer == null) return false; - final rowsAffected = - await (update(playerTable)..where((p) => p.id.equals(playerId))).write( - PlayerTableCompanion(name: Value(name)), - ); + final previousName = previousPlayer.name; + final previousCount = previousPlayer.nameCount; - // Update name count for the new name - final count = await calculateNameCount(name: name); - if (count > 0) { - await (update(playerTable)..where((p) => p.name.equals(name))).write( - PlayerTableCompanion(nameCount: Value(count)), - ); - } + // Determine the nameCount for the renamed player in the new group. + final newNameCount = await _processNameCount(name: name); - if (previousNameCount > 0) { - // Get the player with that name and the hightest nameCount, and update their nameCount to previousNameCount - final player = await getPlayerWithHighestNameCount( - name: previousPlayerName, - ); - if (player != null) { - await updateNameCount( - playerId: player.id, - nameCount: previousNameCount, - ); + final rowsAffected = + await (update( + playerTable, + )..where((p) => p.id.equals(playerId))).write( + PlayerTableCompanion( + name: Value(name), + nameCount: Value(newNameCount), + ), + ); + + // Consolidate the previous name group. + final remainingCount = await getNameCount(name: previousName); + + if (remainingCount == 1) { + // Only one player left + await (update(playerTable)..where((p) => p.name.equals(previousName))) + .write(const PlayerTableCompanion(nameCount: Value(0))); + } else if (remainingCount > 1 && previousCount > 0) { + // Shift every player above the gap down by one to keep numbering in order. + await (update(playerTable)..where( + (p) => + p.name.equals(previousName) & + p.nameCount.isBiggerThanValue(previousCount), + )) + .write( + PlayerTableCompanion.custom( + nameCount: playerTable.nameCount - const Constant(1), + ), + ); } - } - return rowsAffected > 0; + + return rowsAffected > 0; + }); } /// Updates the description of the player with the given [playerId] to @@ -226,6 +245,8 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { /* Name count management */ /// Retrieves the count of players with the given [name]. + /// Returns the highest name count if players with the same name exist, + /// otherwise `null`. Future getNameCount({required String name}) async { final query = select(playerTable)..where((p) => p.name.equals(name)); final result = await query.get(); @@ -264,25 +285,39 @@ class PlayerDao extends DatabaseAccessor with _$PlayerDaoMixin { return null; } + /// Processes the name count for a new player with the given [name]. + ///- 0 Player: returning 0 + ///- 1 Player: returning 2, and initializes the nameCount for the existing player to 1 + ///- Other: returning the existing count + 1 + Future _processNameCount({required String name}) async { + final nameCount = await calculateNameCount(name: name); + if (nameCount == 2) { + // If one other player exists with the same name, initialize the nameCount + await initializeNameCount(name: name); + } + return nameCount; + } + @visibleForTesting + /// Calculates the name count for a new player with the given [name]. + /// - 0 Players: Name count is 0 + /// - 1 Player: Name count is 2 (since the existing player will be 1) + /// - Other: Name count is the existing count + 1 Future calculateNameCount({required String name}) async { final count = await getNameCount(name: name); final int nameCount; - if (count == 1) { - // If one other player exists with the same name, initialize the nameCount - await initializeNameCount(name: name); - // And for the new player, set nameCount to 2 + if (count == 0) { + // If no other players exist with the same name, the returned nameCount is 0 + nameCount = 0; + } else if (count == 1) { + // If one other player with the name count exists, the returned name count is 2 nameCount = 2; - } else if (count > 1) { + } else { // If more than one player exists with the same name, just increment // the nameCount for the new player nameCount = count + 1; - } else { - // If no other players exist with the same name, set nameCount to 0 - nameCount = 0; } - return nameCount; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index a1ed4af..610d2c9 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -19,6 +19,7 @@ "color_red": "Rot", "color_teal": "Türkis", "color_yellow": "Gelb", + "confirm": "Bestätigen", "could_not_add_player": "Spieler:in {playerName} konnte nicht hinzugefügt werden", "create_game": "Spielvorlage erstellen", "create_group": "Gruppe erstellen", @@ -44,11 +45,14 @@ }, "delete_group": "Gruppe löschen", "delete_match": "Spiel löschen", + "delete_player": "Spieler:in löschen", "description": "Beschreibung", "drag_to_set_placement": "Ziehen um Platzierung zu setzen", "edit_game": "Spielvorlage bearbeiten", "edit_group": "Gruppe bearbeiten", "edit_match": "Gruppe bearbeiten", + "edit_name": "Name ändern", + "edit_player": "Spieler bearbeiten", "enter_points": "Punkte eingeben", "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", @@ -66,6 +70,7 @@ "group_name": "Gruppenname", "group_profile": "Gruppenprofil", "groups": "Gruppen", + "groups_part_of": "Gruppen Teil von", "highest_score": "Höchste Punkte", "home": "Startseite", "import_canceled": "Import abgebrochen", @@ -83,6 +88,9 @@ "match_name": "Spieltitel", "match_profile": "Spielprofil", "matches": "Spiele", + "matches_part_of": "Spiele Teil von", + "matches_played": "Spiele gespielt", + "matches_won": "Spiele gewonnen", "members": "Mitglieder", "most_points": "Höchste Punkte", "multiple_winners": "Mehrere Gewinner:innen", @@ -92,6 +100,7 @@ "no_license_text_available": "Kein Lizenztext verfügbar", "no_licenses_found": "Keine Lizenzen gefunden", "no_matches_created_yet": "Noch keine Spiele erstellt", + "no_matches_played_yet": "Noch kein Spiel gespielt", "no_players_created_yet": "Noch keine Spieler:in erstellt", "no_players_found_with_that_name": "Keine Spieler:in mit diesem Namen gefunden", "no_players_selected": "Keine Spieler:innen ausgewählt", @@ -102,10 +111,12 @@ "none": "Kein", "none_group": "Keine", "not_available": "Nicht verfügbar", + "not_part_of_any_group": "Noch keiner Gruppe hinzugefügt", "place": "Platz", "placement": "Platzierung", "played_matches": "Gespielte Spiele", "player_name": "Spieler:innenname", + "player_profile": "Spieler:in-Profil", "players": "Spieler:innen", "point": "Punkt", "points": "Punkte", @@ -127,6 +138,7 @@ "select_winner": "Gewinner:in wählen", "select_winners": "Gewinner:innen wählen", "selected_players": "Ausgewählte Spieler:innen", + "set_name": "Name setzen", "settings": "Einstellungen", "single_loser": "Ein:e Verlierer:in", "single_winner": "Ein:e Gewinner:in", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5ae94cd..a8b0634 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -19,6 +19,7 @@ "color_red": "Red", "color_teal": "Teal", "color_yellow": "Yellow", + "confirm": "Confirm", "could_not_add_player": "Could not add player", "create_game": "Create Game", "create_group": "Create Group", @@ -44,11 +45,14 @@ }, "delete_group": "Delete Group", "delete_match": "Delete Match", + "delete_player": "Delete player?", "description": "Description", "drag_to_set_placement": "Drag to set placement", "edit_game": "Edit Game", "edit_group": "Edit Group", "edit_match": "Edit Match", + "edit_name": "Edit name", + "edit_player": "Edit player", "enter_points": "Enter points", "enter_results": "Enter Results", "error_creating_group": "Error while creating group, please try again", @@ -66,6 +70,7 @@ "group_name": "Group name", "group_profile": "Group Profile", "groups": "Groups", + "groups_part_of": "Groups part of", "highest_score": "Highest Score", "home": "Home", "import_canceled": "Import canceled", @@ -83,6 +88,9 @@ "match_name": "Match name", "match_profile": "Match Profile", "matches": "Matches", + "matches_part_of": "Matches part of", + "matches_played": "Matches played", + "matches_won": "Matches won", "members": "Members", "most_points": "Most Points", "multiple_winners": "Multiple Winners", @@ -92,6 +100,7 @@ "no_license_text_available": "No license text available", "no_licenses_found": "No licenses found", "no_matches_created_yet": "No matches created yet", + "no_matches_played_yet": "No games played yet", "no_players_created_yet": "No players created yet", "no_players_found_with_that_name": "No players found with that name", "no_players_selected": "No players selected", @@ -102,10 +111,12 @@ "none": "None", "none_group": "None", "not_available": "Not available", + "not_part_of_any_group": "Not part of any group yet", "place": "place", "placement": "Placement", "played_matches": "Played Matches", "player_name": "Player name", + "player_profile": "Player Profile", "players": "Players", "point": "Point", "points": "Points", @@ -126,6 +137,7 @@ "select_winner": "Select Winner", "select_winners": "Select Winners", "selected_players": "Selected players", + "set_name": "Set name", "settings": "Settings", "single_loser": "Single Loser", "single_winner": "Single Winner", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index dd538d5..4cee263 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -212,6 +212,12 @@ abstract class AppLocalizations { /// **'Yellow'** String get color_yellow; + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + /// No description provided for @could_not_add_player. /// /// In en, this message translates to: @@ -320,6 +326,12 @@ abstract class AppLocalizations { /// **'Delete Match'** String get delete_match; + /// No description provided for @delete_player. + /// + /// In en, this message translates to: + /// **'Delete player?'** + String get delete_player; + /// No description provided for @description. /// /// In en, this message translates to: @@ -350,6 +362,18 @@ abstract class AppLocalizations { /// **'Edit Match'** String get edit_match; + /// No description provided for @edit_name. + /// + /// In en, this message translates to: + /// **'Edit name'** + String get edit_name; + + /// No description provided for @edit_player. + /// + /// In en, this message translates to: + /// **'Edit player'** + String get edit_player; + /// No description provided for @enter_points. /// /// In en, this message translates to: @@ -452,6 +476,12 @@ abstract class AppLocalizations { /// **'Groups'** String get groups; + /// No description provided for @groups_part_of. + /// + /// In en, this message translates to: + /// **'Groups part of'** + String get groups_part_of; + /// No description provided for @highest_score. /// /// In en, this message translates to: @@ -554,6 +584,24 @@ abstract class AppLocalizations { /// **'Matches'** String get matches; + /// No description provided for @matches_part_of. + /// + /// In en, this message translates to: + /// **'Matches part of'** + String get matches_part_of; + + /// No description provided for @matches_played. + /// + /// In en, this message translates to: + /// **'Matches played'** + String get matches_played; + + /// No description provided for @matches_won. + /// + /// In en, this message translates to: + /// **'Matches won'** + String get matches_won; + /// No description provided for @members. /// /// In en, this message translates to: @@ -608,6 +656,12 @@ abstract class AppLocalizations { /// **'No matches created yet'** String get no_matches_created_yet; + /// No description provided for @no_matches_played_yet. + /// + /// In en, this message translates to: + /// **'No games played yet'** + String get no_matches_played_yet; + /// No description provided for @no_players_created_yet. /// /// In en, this message translates to: @@ -668,6 +722,12 @@ abstract class AppLocalizations { /// **'Not available'** String get not_available; + /// No description provided for @not_part_of_any_group. + /// + /// In en, this message translates to: + /// **'Not part of any group yet'** + String get not_part_of_any_group; + /// No description provided for @place. /// /// In en, this message translates to: @@ -692,6 +752,12 @@ abstract class AppLocalizations { /// **'Player name'** String get player_name; + /// No description provided for @player_profile. + /// + /// In en, this message translates to: + /// **'Player Profile'** + String get player_profile; + /// No description provided for @players. /// /// In en, this message translates to: @@ -812,6 +878,12 @@ abstract class AppLocalizations { /// **'Selected players'** String get selected_players; + /// No description provided for @set_name. + /// + /// In en, this message translates to: + /// **'Set name'** + String get set_name; + /// No description provided for @settings. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 7c5177a..66dca88 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -65,6 +65,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get color_yellow => 'Gelb'; + @override + String get confirm => 'Bestätigen'; + @override String could_not_add_player(Object playerName) { return 'Spieler:in $playerName konnte nicht hinzugefügt werden'; @@ -131,6 +134,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get delete_match => 'Spiel löschen'; + @override + String get delete_player => 'Spieler:in löschen'; + @override String get description => 'Beschreibung'; @@ -146,6 +152,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get edit_match => 'Gruppe bearbeiten'; + @override + String get edit_name => 'Name ändern'; + + @override + String get edit_player => 'Spieler bearbeiten'; + @override String get enter_points => 'Punkte eingeben'; @@ -201,6 +213,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get groups => 'Gruppen'; + @override + String get groups_part_of => 'Gruppen Teil von'; + @override String get highest_score => 'Höchste Punkte'; @@ -252,6 +267,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get matches => 'Spiele'; + @override + String get matches_part_of => 'Spiele Teil von'; + + @override + String get matches_played => 'Spiele gespielt'; + + @override + String get matches_won => 'Spiele gewonnen'; + @override String get members => 'Mitglieder'; @@ -279,6 +303,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get no_matches_created_yet => 'Noch keine Spiele erstellt'; + @override + String get no_matches_played_yet => 'Noch kein Spiel gespielt'; + @override String get no_players_created_yet => 'Noch keine Spieler:in erstellt'; @@ -310,6 +337,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get not_available => 'Nicht verfügbar'; + @override + String get not_part_of_any_group => 'Noch keiner Gruppe hinzugefügt'; + @override String get place => 'Platz'; @@ -322,6 +352,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get player_name => 'Spieler:innenname'; + @override + String get player_profile => 'Spieler:in-Profil'; + @override String get players => 'Spieler:innen'; @@ -387,6 +420,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selected_players => 'Ausgewählte Spieler:innen'; + @override + String get set_name => 'Name setzen'; + @override String get settings => 'Einstellungen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index bc083e5..b9e467a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -65,6 +65,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get color_yellow => 'Yellow'; + @override + String get confirm => 'Confirm'; + @override String could_not_add_player(Object playerName) { return 'Could not add player'; @@ -131,6 +134,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete_match => 'Delete Match'; + @override + String get delete_player => 'Delete player?'; + @override String get description => 'Description'; @@ -146,6 +152,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get edit_match => 'Edit Match'; + @override + String get edit_name => 'Edit name'; + + @override + String get edit_player => 'Edit player'; + @override String get enter_points => 'Enter points'; @@ -201,6 +213,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get groups => 'Groups'; + @override + String get groups_part_of => 'Groups part of'; + @override String get highest_score => 'Highest Score'; @@ -252,6 +267,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get matches => 'Matches'; + @override + String get matches_part_of => 'Matches part of'; + + @override + String get matches_played => 'Matches played'; + + @override + String get matches_won => 'Matches won'; + @override String get members => 'Members'; @@ -279,6 +303,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get no_matches_created_yet => 'No matches created yet'; + @override + String get no_matches_played_yet => 'No games played yet'; + @override String get no_players_created_yet => 'No players created yet'; @@ -310,6 +337,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get not_available => 'Not available'; + @override + String get not_part_of_any_group => 'Not part of any group yet'; + @override String get place => 'place'; @@ -322,6 +352,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get player_name => 'Player name'; + @override + String get player_profile => 'Player Profile'; + @override String get players => 'Players'; @@ -387,6 +420,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selected_players => 'Selected players'; + @override + String get set_name => 'Set name'; + @override String get settings => 'Settings'; diff --git a/lib/presentation/views/main_menu/group_view/create_group_view.dart b/lib/presentation/views/main_menu/group_view/create_group_view.dart index 84efbe1..72e4c69 100644 --- a/lib/presentation/views/main_menu/group_view/create_group_view.dart +++ b/lib/presentation/views/main_menu/group_view/create_group_view.dart @@ -89,6 +89,7 @@ class _CreateGroupViewState extends State { Expanded( child: PlayerSelection( initialSelectedPlayers: initialSelectedPlayers, + onPlayerCreated: () => widget.onMembersChanged?.call(), onChanged: (value) { setState(() { selectedPlayers = [...value]; @@ -134,6 +135,7 @@ class _CreateGroupViewState extends State { if (!mounted) return; if (success) { + widget.onMembersChanged?.call(); await HapticFeedback.successNotification(); if (mounted) { Navigator.pop(context, updatedGroup); @@ -157,7 +159,6 @@ class _CreateGroupViewState extends State { final success = await db.groupDao.addGroup( group: Group(name: groupName, members: selectedPlayers), ); - return success; } diff --git a/lib/presentation/views/main_menu/group_view/group_view.dart b/lib/presentation/views/main_menu/group_view/group_view.dart index c8a9398..e53b661 100644 --- a/lib/presentation/views/main_menu/group_view/group_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_view.dart @@ -77,6 +77,7 @@ class _GroupViewState extends State { ); } return GroupTile( + onPlayerChanged: loadGroups, group: groups[index], onTap: () async { await Navigator.push( @@ -106,13 +107,10 @@ class _GroupViewState extends State { context, adaptivePageRoute( builder: (context) { - return const CreateGroupView(); + return CreateGroupView(onMembersChanged: loadGroups); }, ), ); - setState(() { - loadGroups(); - }); }, ), ), diff --git a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart index 85bb936..c8790be 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/create_match_view.dart @@ -196,6 +196,7 @@ class _CreateMatchViewState extends State { child: PlayerSelection( key: ValueKey(selectedGroup?.id ?? 'no_group'), initialSelectedPlayers: selectedPlayers, + onPlayerCreated: () => widget.onMatchesUpdated?.call(), onChanged: (value) { setState(() { selectedPlayers = value; diff --git a/lib/presentation/views/main_menu/match_view/match_view.dart b/lib/presentation/views/main_menu/match_view/match_view.dart index a7f60c6..83ff069 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -97,6 +97,7 @@ class _MatchViewState extends State { child: Padding( padding: const EdgeInsets.only(bottom: 12.0), child: MatchTile( + onPlayerEdited: loadMatches, width: MediaQuery.sizeOf(context).width * 0.95, onTap: () async { Navigator.push( diff --git a/lib/presentation/views/main_menu/player_detail_view.dart b/lib/presentation/views/main_menu/player_detail_view.dart new file mode 100644 index 0000000..e26c327 --- /dev/null +++ b/lib/presentation/views/main_menu/player_detail_view.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.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/game.dart'; +import 'package:tallee/data/models/group.dart'; +import 'package:tallee/data/models/match.dart'; +import 'package:tallee/data/models/player.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/widgets/app_skeleton.dart'; +import 'package:tallee/presentation/widgets/buttons/haptic_icon_button.dart'; +import 'package:tallee/presentation/widgets/buttons/main_menu_button.dart'; +import 'package:tallee/presentation/widgets/colored_icon_container.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_alert_dialog.dart'; +import 'package:tallee/presentation/widgets/dialog/custom_dialog_action.dart'; +import 'package:tallee/presentation/widgets/text_input/text_input_field.dart'; +import 'package:tallee/presentation/widgets/tiles/info_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; + +class PlayerDetailView extends StatefulWidget { + const PlayerDetailView({ + super.key, + required this.player, + required this.callback, + }); + + /// The player to display + final Player player; + + final VoidCallback callback; + + @override + State createState() => _PlayerDetailViewState(); +} + +class _PlayerDetailViewState extends State { + late final AppDatabase db; + late Player _player; + late String playerNameCount; + bool isLoading = true; + + /// Total matches played by this player + int totalMatches = 0; + + /// Total matches won by this player + int matchesWon = 0; + + /// Total groups this player belongs to + int totalGroups = 0; + + /// Full list of groups this player belongs to + List playerGroups = List.filled( + 4, + Group(name: 'Skeleton group', members: []), + ); + + /// Full list of matches this player played in + List playerMatches = List.filled( + 4, + Match( + name: 'Skeleton match', + game: Game(name: 'Game name', ruleset: Ruleset.singleWinner), + players: [], + ), + ); + + TextEditingController nameController = TextEditingController(); + + @override + void initState() { + super.initState(); + _player = widget.player; + db = Provider.of(context, listen: false); + playerNameCount = getNameCountText(_player); + _loadData(); + } + + @override + void dispose() { + nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(loc.player_profile), + actions: [ + HapticIconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + showDialog( + context: context, + builder: (context) => CustomAlertDialog( + title: loc.delete_player, + content: Text(loc.this_cannot_be_undone), + actions: [ + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(true), + text: loc.delete, + ), + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(false), + buttonType: ButtonType.secondary, + text: loc.cancel, + ), + ], + ), + ).then((confirmed) async { + if (confirmed! && context.mounted) { + //TODO: implement player deletion in db + if (!context.mounted) return; + Navigator.pop(context); + widget.callback(); + } + }); + }, + ), + ], + ), + body: SafeArea( + child: Stack( + alignment: Alignment.center, + children: [ + ListView( + padding: const EdgeInsets.only( + left: 12, + right: 12, + top: 20, + bottom: 100, + ), + children: [ + const Center( + child: ColoredIconContainer( + icon: Icons.person, + containerSize: 55, + iconSize: 38, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _player.name, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + Text( + playerNameCount, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: CustomTheme.textColor.withAlpha(120), + ), + textAlign: TextAlign.center, + ), + ], + ), + const SizedBox(height: 5), + Text( + '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(_player.createdAt)}', + style: const TextStyle( + fontSize: 12, + color: CustomTheme.textColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + InfoTile( + title: '${loc.matches_part_of} ($totalMatches)', + icon: Icons.sports_esports, + horizontalAlignment: CrossAxisAlignment.start, + content: AppSkeleton( + enabled: isLoading, + fixLayoutBuilder: true, + alignment: Alignment.topLeft, + child: playerMatches.isNotEmpty + ? Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: playerMatches.map((match) { + return TextIconTile( + text: match.name, + iconEnabled: false, + ); + }).toList(), + ) + : Text( + loc.no_matches_played_yet, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: '${loc.groups_part_of} ($totalGroups)', + icon: Icons.people, + horizontalAlignment: CrossAxisAlignment.start, + content: AppSkeleton( + enabled: isLoading, + fixLayoutBuilder: true, + alignment: Alignment.topLeft, + child: playerGroups.isNotEmpty + ? Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: 12, + runSpacing: 8, + children: playerGroups.map((group) { + return TextIconTile( + text: group.name, + iconEnabled: false, + ); + }).toList(), + ) + : Text( + loc.not_part_of_any_group, + style: const TextStyle( + fontSize: 14, + color: CustomTheme.textColor, + ), + ), + ), + ), + const SizedBox(height: 15), + InfoTile( + title: loc.statistics, + icon: Icons.bar_chart, + content: AppSkeleton( + enabled: isLoading, + fixLayoutBuilder: true, + child: Column( + children: [ + _buildStatRow( + loc.matches_played, + totalMatches.toString(), + ), + _buildStatRow(loc.matches_won, matchesWon.toString()), + _buildStatRow( + loc.winrate, + '${totalMatches == 0 ? 0 : ((matchesWon / totalMatches) * 100).round()}%', + ), + ], + ), + ), + ), + ], + ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom, + child: MainMenuButton( + text: loc.edit_player, + icon: Icons.edit, + onPressed: () async { + nameController.text = _player.name; + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + return CustomAlertDialog( + title: loc.edit_name, + content: TextInputField( + controller: nameController, + hintText: loc.set_name, + onChanged: (_) => setDialogState(() {}), + ), + actions: [ + CustomDialogAction( + onPressed: isConfirmButtonEnabled() + ? () => Navigator.of(context).pop(true) + : null, + text: loc.confirm, + ), + CustomDialogAction( + onPressed: () => Navigator.of(context).pop(false), + buttonType: ButtonType.secondary, + text: loc.cancel, + ), + ], + ); + }, + ), + ).then((confirmed) async { + if (confirmed! && context.mounted) { + final newName = nameController.text.trim(); + + if (newName != _player.name) { + final fetchedPlayerNameCount = await db.playerDao + .getNameCount(name: newName); + await db.playerDao.updatePlayerName( + playerId: _player.id, + name: newName, + ); + widget.callback.call(); + setState(() { + _player = Player( + name: newName, + createdAt: _player.createdAt, + id: _player.id, + nameCount: _player.nameCount, + description: _player.description, + ); + + // If there is already a player with the same name, + // the count of that player is 0, so we start counting from 2 to get the correct count for this player. If there are no players with the same name, we just show the name without a count. + playerNameCount = fetchedPlayerNameCount == 0 + ? '' + : ' #${fetchedPlayerNameCount + 1}'; + }); + } + } + }); + }, + ), + ), + ], + ), + ), + ); + } + + /// Loads statistics for this player + Future _loadData() async { + isLoading = true; + final fetchedMatches = await db.matchDao.getMatchesByPlayer( + playerId: _player.id, + ); + final fetchedGroups = await db.groupDao.getGroupsByPlayer( + playerId: _player.id, + ); + + if (!mounted) return; + + setState(() { + playerMatches = fetchedMatches; + totalMatches = fetchedMatches.length; + matchesWon = fetchedMatches + .where((match) => match.mvp.any((mvp) => mvp.id == _player.id)) + .length; + playerGroups = fetchedGroups; + totalGroups = fetchedGroups.length; + isLoading = false; + }); + } + + /// Builds a single statistic row with a label and value + /// - [label]: The label of the statistic + /// - [value]: The value of the statistic + Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 16, + color: CustomTheme.textColor, + ), + ), + ], + ), + Text( + value, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + bool isConfirmButtonEnabled() { + return nameController.text.trim().isNotEmpty; + } +} diff --git a/lib/presentation/widgets/app_skeleton.dart b/lib/presentation/widgets/app_skeleton.dart index 8a21320..abdfb8d 100644 --- a/lib/presentation/widgets/app_skeleton.dart +++ b/lib/presentation/widgets/app_skeleton.dart @@ -6,11 +6,13 @@ class AppSkeleton extends StatefulWidget { /// - [child]: The widget tree to apply the skeleton effect to. /// - [enabled]: A boolean to enable or disable the skeleton effect. /// - [fixLayoutBuilder]: A boolean to fix the layout builder for AnimatedSwitcher. + /// - [alignment]: The alignment used for the custom layout builder and optional Align wrapper. Defaults to [Alignment.center]. const AppSkeleton({ super.key, required this.child, this.enabled = true, this.fixLayoutBuilder = false, + this.alignment = Alignment.center, }); /// The widget tree to apply the skeleton effect to. @@ -22,6 +24,9 @@ class AppSkeleton extends StatefulWidget { /// A boolean to fix the layout builder for AnimatedSwitcher. final bool fixLayoutBuilder; + /// The alignment used for the custom layout builder and optional Align wrapper + final Alignment alignment; + @override State createState() => _AppSkeletonState(); } @@ -45,13 +50,14 @@ class _AppSkeletonState extends State { layoutBuilder: !widget.fixLayoutBuilder ? AnimatedSwitcher.defaultLayoutBuilder : (Widget? currentChild, List previousChildren) { - return Stack( - alignment: Alignment.topCenter, - children: [...previousChildren, ?currentChild], - ); + final children = [...previousChildren]; + if (currentChild != null) children.add(currentChild); + return Stack(alignment: widget.alignment, children: children); }, ), - child: widget.child, + child: widget.fixLayoutBuilder + ? Align(alignment: widget.alignment, child: widget.child) + : widget.child, ); } } diff --git a/lib/presentation/widgets/buttons/animated_dialog_button.dart b/lib/presentation/widgets/buttons/animated_dialog_button.dart index 8c8765e..5062b23 100644 --- a/lib/presentation/widgets/buttons/animated_dialog_button.dart +++ b/lib/presentation/widgets/buttons/animated_dialog_button.dart @@ -11,7 +11,7 @@ class AnimatedDialogButton extends StatefulWidget { const AnimatedDialogButton({ super.key, required this.buttonText, - required this.onPressed, + this.onPressed, this.buttonConstraints, this.buttonType = ButtonType.primary, this.isDescructive = false, @@ -19,7 +19,7 @@ class AnimatedDialogButton extends StatefulWidget { final String buttonText; - final VoidCallback onPressed; + final VoidCallback? onPressed; final BoxConstraints? buttonConstraints; @@ -38,28 +38,38 @@ class _AnimatedDialogButtonState extends State { Widget build(BuildContext context) { final textStyling = _getTextStyling(); final buttonDecoration = _getButtonDecoration(); + bool isDisabled = widget.onPressed == null; - return GestureDetector( - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - onTap: widget.onPressed, - child: AnimatedScale( - scale: _isPressed ? 0.95 : 1.0, - duration: const Duration(milliseconds: 100), - child: AnimatedOpacity( - opacity: _isPressed ? 0.6 : 1.0, - duration: const Duration(milliseconds: 100), - child: Center( - child: Container( - constraints: widget.buttonConstraints, - decoration: buttonDecoration, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - margin: const EdgeInsets.symmetric(vertical: 8), - child: Text( - widget.buttonText, - style: textStyling, - textAlign: TextAlign.center, + return IgnorePointer( + ignoring: isDisabled, + child: Opacity( + opacity: isDisabled ? 0.5 : 1.0, + child: GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + onTap: widget.onPressed, + child: AnimatedScale( + scale: _isPressed ? 0.95 : 1.0, + duration: const Duration(milliseconds: 100), + child: AnimatedOpacity( + opacity: _isPressed ? 0.6 : 1.0, + duration: const Duration(milliseconds: 100), + child: Center( + child: Container( + constraints: widget.buttonConstraints, + decoration: buttonDecoration, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + margin: const EdgeInsets.symmetric(vertical: 8), + child: Text( + widget.buttonText, + style: textStyling, + textAlign: TextAlign.center, + ), + ), ), ), ), diff --git a/lib/presentation/widgets/dialog/custom_alert_dialog.dart b/lib/presentation/widgets/dialog/custom_alert_dialog.dart index 606fc49..7dfba98 100644 --- a/lib/presentation/widgets/dialog/custom_alert_dialog.dart +++ b/lib/presentation/widgets/dialog/custom_alert_dialog.dart @@ -19,7 +19,6 @@ class CustomAlertDialog extends StatelessWidget { final String title; final Widget content; final List actions; - @override Widget build(BuildContext context) { return AlertDialog( diff --git a/lib/presentation/widgets/dialog/custom_dialog_action.dart b/lib/presentation/widgets/dialog/custom_dialog_action.dart index 26dc40d..0c0b2e0 100644 --- a/lib/presentation/widgets/dialog/custom_dialog_action.dart +++ b/lib/presentation/widgets/dialog/custom_dialog_action.dart @@ -10,7 +10,7 @@ class CustomDialogAction extends StatelessWidget { /// - [onPressed]: Callback function that is triggered when the button is pressed. const CustomDialogAction({ super.key, - required this.onPressed, + this.onPressed, required this.text, this.buttonType = ButtonType.primary, this.isDestructive = false, @@ -20,17 +20,18 @@ class CustomDialogAction extends StatelessWidget { final ButtonType buttonType; - final VoidCallback onPressed; + final VoidCallback? onPressed; final bool isDestructive; - @override Widget build(BuildContext context) { return AnimatedDialogButton( - onPressed: () async { - await HapticFeedback.selectionClick(); - onPressed.call(); - }, + onPressed: onPressed != null + ? () async { + await HapticFeedback.selectionClick(); + onPressed?.call(); + } + : null, buttonText: text, buttonType: buttonType, isDescructive: isDestructive, diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 00d6c11..6a62c95 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -26,6 +26,7 @@ class PlayerSelection extends StatefulWidget { this.availablePlayers, this.initialSelectedPlayers, required this.onChanged, + this.onPlayerCreated, }); /// An optional list of players to choose from. If null, all players from the database are used. @@ -37,6 +38,9 @@ class PlayerSelection extends StatefulWidget { /// A callback function that is invoked whenever the selection changes, final Function(List value) onChanged; + /// A callback function that is invoked when a player was created in this widget + final VoidCallback? onPlayerCreated; + @override State createState() => _PlayerSelectionState(); } @@ -323,6 +327,7 @@ class _PlayerSelectionState extends State { /// Updates the state after successfully adding a new player. void _handleSuccessfulPlayerCreation(Player player) { + widget.onPlayerCreated?.call(); selectedPlayers.insert(0, player); widget.onChanged([...selectedPlayers]); allPlayers.add(player); diff --git a/lib/presentation/widgets/tiles/group_tile.dart b/lib/presentation/widgets/tiles/group_tile.dart index f6c406e..c01dcbe 100644 --- a/lib/presentation/widgets/tiles/group_tile.dart +++ b/lib/presentation/widgets/tiles/group_tile.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/models/group.dart'; +import 'package:tallee/presentation/views/main_menu/player_detail_view.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; class GroupTile extends StatefulWidget { @@ -15,6 +17,7 @@ class GroupTile extends StatefulWidget { required this.group, this.isHighlighted = false, this.onTap, + this.onPlayerChanged, }); /// The group data to be displayed. @@ -26,6 +29,9 @@ class GroupTile extends StatefulWidget { /// Callback function to be executed when the tile is tapped. final VoidCallback? onTap; + /// Callback function to be executed when the players in the group are changed. + final VoidCallback? onPlayerChanged; + @override State createState() => _GroupTileState(); } @@ -92,6 +98,19 @@ class _GroupTileState extends State { text: member.name, suffixText: getNameCountText(member), iconEnabled: false, + onTileTap: () { + Navigator.push( + context, + adaptivePageRoute( + builder: (context) => PlayerDetailView( + player: member, + callback: () { + widget.onPlayerChanged?.call(); + }, + ), + ), + ); + }, ), ], ), diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index 6a81dc3..c3d0242 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -3,11 +3,13 @@ import 'dart:core' hide Match; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.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/models/match.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/views/main_menu/player_detail_view.dart'; import 'package:tallee/presentation/widgets/game_label.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; @@ -24,6 +26,7 @@ class MatchTile extends StatefulWidget { required this.onTap, this.width, this.compact = false, + this.onPlayerEdited, }); /// The match data to be displayed. @@ -32,6 +35,9 @@ class MatchTile extends StatefulWidget { /// The callback invoked when the tile is tapped. final VoidCallback onTap; + /// The callback invoked when the players are edited + final VoidCallback? onPlayerEdited; + /// Optional width for the tile. final double? width; @@ -224,6 +230,19 @@ class _MatchTileState extends State { text: player.name, suffixText: getNameCountText(player), iconEnabled: false, + onTileTap: () { + Navigator.push( + context, + adaptivePageRoute( + builder: (context) => PlayerDetailView( + player: player, + callback: () { + widget.onPlayerEdited?.call(); + }, + ), + ), + ); + }, ); }).toList(), ), diff --git a/lib/presentation/widgets/tiles/text_icon_tile.dart b/lib/presentation/widgets/tiles/text_icon_tile.dart index 541b6ae..a5ef399 100644 --- a/lib/presentation/widgets/tiles/text_icon_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_tile.dart @@ -12,6 +12,7 @@ class TextIconTile extends StatelessWidget { this.suffixText = '', this.iconEnabled = true, this.onIconTap, + this.onTileTap, }); /// The text to display in the tile. @@ -25,52 +26,58 @@ class TextIconTile extends StatelessWidget { /// The callback to be invoked when the icon is tapped. final VoidCallback? onIconTap; + /// The callback to be invoked when the tile is tapped. + final VoidCallback? onTileTap; + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: CustomTheme.onBoxColor, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - if (iconEnabled) const SizedBox(width: 3), - Flexible( - child: RichText( - overflow: TextOverflow.ellipsis, - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: text, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + return GestureDetector( + onTap: onTileTap, + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: CustomTheme.onBoxColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + if (iconEnabled) const SizedBox(width: 3), + Flexible( + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan( + text: text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - ), - TextSpan( - text: suffixText, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: CustomTheme.textColor.withAlpha(120), + TextSpan( + text: suffixText, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: CustomTheme.textColor.withAlpha(120), + ), ), - ), - ], + ], + ), ), ), - ), - if (iconEnabled) ...[ - const SizedBox(width: 3), - GestureDetector( - onTap: onIconTap, - child: const Icon(Icons.close, size: 20), - ), + if (iconEnabled) ...[ + const SizedBox(width: 3), + GestureDetector( + onTap: onIconTap, + child: const Icon(Icons.close, size: 20), + ), + ], ], - ], + ), ), ); } diff --git a/test/db_tests/aggregates/group_test.dart b/test/db_tests/aggregates/group_test.dart index 1498523..3ea625f 100644 --- a/test/db_tests/aggregates/group_test.dart +++ b/test/db_tests/aggregates/group_test.dart @@ -194,6 +194,31 @@ void main() { expect(allGroups, isEmpty); }); + test('getGroupsByPlayer() works correctly', () async { + await database.groupDao.addGroupsAsList( + groups: [testGroup1, testGroup2], + ); + + final groups = await database.groupDao.getGroupsByPlayer( + playerId: testPlayer2.id, + ); + + expect(groups, hasLength(2)); + expect(groups.any((group) => group.id == testGroup1.id), isTrue); + expect(groups.any((group) => group.id == testGroup2.id), isTrue); + }); + + test( + 'getGroupsByPlayer() returns empty list for non-existent player', + () async { + final groups = await database.groupDao.getGroupsByPlayer( + playerId: 'non-existent-player-id', + ); + + expect(groups, isEmpty); + }, + ); + test('addGroupsAsList() with duplicate groups only adds once', () async { await database.groupDao.addGroupsAsList( groups: [testGroup1, testGroup1, testGroup1], diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index 37c1cd0..00e6e46 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -260,6 +260,34 @@ void main() { expect(match.group!.id, testGroup1.id); }); + test('getMatchesByPlayer() works correctly', () async { + await database.matchDao.addMatchesAsList( + matches: [testMatch1, testMatch2], + ); + + final matches = await database.matchDao.getMatchesByPlayer( + playerId: testPlayer1.id, + ); + + expect(matches, hasLength(1)); + expect(matches.first.id, testMatch2.id); + expect( + matches.first.players.any((p) => p.id == testPlayer1.id), + isTrue, + ); + }); + + test( + 'getMatchesByPlayer() returns empty list for non-existent player', + () async { + final matches = await database.matchDao.getMatchesByPlayer( + playerId: 'non-existing-player-id', + ); + + expect(matches, isEmpty); + }, + ); + test('getMatchCount() works correctly', () async { var count = await database.matchDao.getMatchCount(); expect(count, 0); diff --git a/test/db_tests/entities/player_test.dart b/test/db_tests/entities/player_test.dart index bfcced4..4105df1 100644 --- a/test/db_tests/entities/player_test.dart +++ b/test/db_tests/entities/player_test.dart @@ -233,6 +233,95 @@ void main() { expect(allPlayers, isEmpty); }); + test('updatePlayerName() sets correct nameCount with 2 player', () async { + await database.playerDao.addPlayer(player: testPlayer1); + await database.playerDao.addPlayer(player: testPlayer2); + + final newName = testPlayer1.name; + await database.playerDao.updatePlayerName( + playerId: testPlayer2.id, + name: newName, + ); + + var player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 1); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer2.id, + ); + expect(player.nameCount, 2); + + await database.playerDao.updatePlayerName( + playerId: testPlayer1.id, + name: 'different name', + ); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 0); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer2.id, + ); + expect(player.nameCount, 0); + }); + + test('updatePlayerName() sets correct nameCount with 3 player', () async { + await database.playerDao.addPlayersAsList( + players: [testPlayer1, testPlayer2, testPlayer3], + ); + + // Changing both names to player 1's name + final newName = testPlayer1.name; + await database.playerDao.updatePlayerName( + playerId: testPlayer2.id, + name: newName, + ); + await database.playerDao.updatePlayerName( + playerId: testPlayer3.id, + name: newName, + ); + + var player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 1); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer2.id, + ); + expect(player.nameCount, 2); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer3.id, + ); + expect(player.nameCount, 3); + + // Changing the middle players name + await database.playerDao.updatePlayerName( + playerId: testPlayer2.id, + name: 'different name', + ); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer1.id, + ); + expect(player.nameCount, 1); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer2.id, + ); + expect(player.nameCount, 0); + + player = await database.playerDao.getPlayerById( + playerId: testPlayer3.id, + ); + expect(player.nameCount, 2); + }); + test('updatePlayerDescription() works correctly', () async { await database.playerDao.addPlayer(player: testPlayer1); @@ -372,14 +461,22 @@ void main() { final player1 = Player(name: testPlayer1.name, description: ''); await database.playerDao.addPlayer(player: player1); + final player2 = Player(name: testPlayer1.name, description: ''); + await database.playerDao.addPlayer(player: player2); + var players = await database.playerDao.getAllPlayers(); - expect(players.length, 2); + expect(players.length, 3); players.sort((a, b) => a.nameCount.compareTo(b.nameCount)); for (int i = 0; i < players.length - 1; i++) { expect(players[i].nameCount, i + 1); } + + // ids are correct in the right order + expect(players[0].id, testPlayer1.id); + expect(players[1].id, player1.id); + expect(players[2].id, player2.id); }, ); @@ -404,24 +501,62 @@ void main() { for (int i = 0; i < players.length - 1; i++) { expect(players[i].nameCount, i + 1); } + + // ids are correct in the right order + expect(players[0].id, testPlayer1.id); + expect(players[1].id, player1.id); + expect(players[2].id, player2.id); + expect(players[3].id, player3.id); }, ); - test('getNameCount works correctly', () async { + test('getNameCount works correctly', () async { + final player1 = Player(name: testPlayer1.name); final player2 = Player(name: testPlayer1.name); - final player3 = Player(name: testPlayer1.name); - await database.playerDao.addPlayersAsList( - players: [testPlayer1, player2, player3], + await database.playerDao.addPlayer(player: testPlayer1); + + var nameCount = await database.playerDao.getNameCount( + name: testPlayer1.name, ); - final nameCount = await database.playerDao.getNameCount( + expect(nameCount, 1); + + await database.playerDao.addPlayersAsList(players: [player1, player2]); + + nameCount = await database.playerDao.getNameCount( name: testPlayer1.name, ); expect(nameCount, 3); }); + test('calculateNameCount works correctly', () async { + final player1 = Player(name: testPlayer1.name); + final player2 = Player(name: testPlayer1.name); + + // Case 1: No existing players with the name + var nameCount = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(nameCount, 0); + + // Case 2: One existing player with the name. Should return 2 for + // the new player + await database.playerDao.addPlayer(player: testPlayer1); + nameCount = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(nameCount, 2); + + // Case 3: Multiple existing players with the name. Should return count + 1 + await database.playerDao.addPlayersAsList(players: [player1, player2]); + nameCount = await database.playerDao.calculateNameCount( + name: testPlayer1.name, + ); + expect(nameCount, 4); + }); + test('updateNameCount works correctly', () async { await database.playerDao.addPlayer(player: testPlayer1); @@ -441,14 +576,24 @@ void main() { final player2 = Player(name: testPlayer1.name, description: ''); final player3 = Player(name: testPlayer1.name, description: ''); - await database.playerDao.addPlayersAsList( - players: [testPlayer1, player2, player3], - ); - - final player = await database.playerDao.getPlayerWithHighestNameCount( + await database.playerDao.addPlayer(player: testPlayer1); + var player = await database.playerDao.getPlayerWithHighestNameCount( name: testPlayer1.name, ); + expect(player, isNotNull); + expect(player!.nameCount, 0); + await database.playerDao.addPlayer(player: player2); + player = await database.playerDao.getPlayerWithHighestNameCount( + name: testPlayer1.name, + ); + expect(player, isNotNull); + expect(player!.nameCount, 2); + + await database.playerDao.addPlayer(player: player3); + player = await database.playerDao.getPlayerWithHighestNameCount( + name: testPlayer1.name, + ); expect(player, isNotNull); expect(player!.nameCount, 3); }); @@ -460,32 +605,6 @@ void main() { expect(player, isNull); }); - test('calculateNameCount works correctly', () async { - // Case 1: No existing players with the name - var count = await database.playerDao.calculateNameCount( - name: testPlayer1.name, - ); - expect(count, 0); - - // Case 2: One existing player with the name. Should update that - // player's nameCount to 1 and return 2 for the new player - await database.playerDao.addPlayer(player: testPlayer1); - - count = await database.playerDao.calculateNameCount( - name: testPlayer1.name, - ); - expect(count, 2); - - // Case 3: Multiple existing players with the name. - final player2 = Player(name: testPlayer1.name, nameCount: count); - await database.playerDao.addPlayer(player: player2); - - count = await database.playerDao.calculateNameCount( - name: testPlayer1.name, - ); - expect(count, 3); - }); - test('getPlayerWithHighestNameCount with non existing player', () async { await database.playerDao.addPlayer(player: testPlayer1); await database.playerDao.initializeNameCount(name: testPlayer1.name);