diff --git a/analysis_options.yaml b/analysis_options.yaml index c0978e6..dc1e1c5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,9 @@ include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart + linter: rules: avoid_print: false @@ -11,8 +15,4 @@ linter: prefer_const_literals_to_create_immutables: true unnecessary_const: true lines_longer_than_80_chars: false - constant_identifier_names: false - -analyzer: - exclude: - - lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart + constant_identifier_names: false \ No newline at end of file diff --git a/assets/schema.json b/assets/schema.json index f5e363b..6bcbe45 100644 --- a/assets/schema.json +++ b/assets/schema.json @@ -147,13 +147,19 @@ "type": "string" }, "endedAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "gameId": { "type": "string" }, "groupId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "playerIds": { "type": "array", @@ -163,22 +169,28 @@ }, "scores": { "type": "object", - "items": { - "type": "array", - "items": { - "type": "string", - "properties": { - "roundNumber": { - "type": "number" + "additionalProperties": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "roundNumber": { + "type": "number" + }, + "score": { + "type": "number" + }, + "change": { + "type": "number" + } }, - "score": { - "type": "number" - }, - "change": { - "type": "number" - } + "required": ["roundNumber", "score", "change"], + "additionalProperties": false } - } + ] } }, "notes": { diff --git a/lib/core/common.dart b/lib/core/common.dart index 399872c..8027180 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -51,3 +51,11 @@ String getNameCountText(Player player) { } return ''; } + +String getPointLabel(AppLocalizations loc, int points) { + if (points == 1) { + return '$points ${loc.point}'; + } else { + return '$points ${loc.points}'; + } +} diff --git a/lib/core/custom_theme.dart b/lib/core/custom_theme.dart index d1b158e..3274db9 100644 --- a/lib/core/custom_theme.dart +++ b/lib/core/custom_theme.dart @@ -85,21 +85,21 @@ class CustomTheme { ); static const SearchBarThemeData searchBarTheme = SearchBarThemeData( - textStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.textColor)), - hintStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.hintColor)), + textStyle: WidgetStatePropertyAll(TextStyle(color: textColor)), + hintStyle: WidgetStatePropertyAll(TextStyle(color: hintColor)), ); static final RadioThemeData radioTheme = RadioThemeData( fillColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return CustomTheme.primaryColor; + return primaryColor; } - return CustomTheme.textColor; + return textColor; }), ); static const InputDecorationTheme inputDecorationTheme = InputDecorationTheme( - labelStyle: TextStyle(color: CustomTheme.textColor), - hintStyle: TextStyle(color: CustomTheme.hintColor), + labelStyle: TextStyle(color: textColor), + hintStyle: TextStyle(color: hintColor), ); } diff --git a/lib/data/dao/match_dao.dart b/lib/data/dao/match_dao.dart index 81925c7..93df7d7 100644 --- a/lib/data/dao/match_dao.dart +++ b/lib/data/dao/match_dao.dart @@ -34,7 +34,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { matchId: row.id, ); - final winner = await db.scoreEntryDao.getWinner(matchId: row.id); return Match( id: row.id, name: row.name, @@ -45,7 +44,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { createdAt: row.createdAt, endedAt: row.endedAt, scores: scores, - winner: winner, ); }), ); @@ -68,8 +66,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { final scores = await db.scoreEntryDao.getAllMatchScores(matchId: matchId); - final winner = await db.scoreEntryDao.getWinner(matchId: matchId); - return Match( id: result.id, name: result.name, @@ -80,7 +76,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { createdAt: result.createdAt, endedAt: result.endedAt, scores: scores, - winner: winner, ); } @@ -110,19 +105,14 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { } for (final pid in match.scores.keys) { - final playerScores = match.scores[pid]!; - await db.scoreEntryDao.addScoresAsList( - entrys: playerScores, - playerId: pid, - matchId: match.id, - ); - } - - if (match.winner != null) { - await db.scoreEntryDao.setWinner( - matchId: match.id, - playerId: match.winner!.id, - ); + final playerScores = match.scores[pid]; + if (playerScores != null) { + await db.scoreEntryDao.addScore( + entry: playerScores, + playerId: pid, + matchId: match.id, + ); + } } }); } @@ -140,6 +130,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { uniqueGames[match.game.id] = match.game; } + // Add games if (uniqueGames.isNotEmpty) { await db.batch( (b) => b.insertAll( @@ -162,7 +153,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ); } - // Add all groups of the matches in batch + // Add groups await db.batch( (b) => b.insertAll( db.groupTable, @@ -181,7 +172,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ), ); - // Add all matches in batch + // Add matches await db.batch( (b) => b.insertAll( matchTable, @@ -202,7 +193,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ), ); - // Add all players of the matches in batch (unique) + // Add players final uniquePlayers = {}; for (final match in matches) { for (final p in match.players) { @@ -235,7 +226,27 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { ); } - // Add all player-match associations in batch + await db.batch((b) { + for (final match in matches) { + for (final entry in match.scores.entries) { + if (entry.value != null) { + b.insert( + db.scoreEntryTable, + ScoreEntryTableCompanion.insert( + matchId: match.id, + playerId: entry.key, + score: entry.value!.score, + roundNumber: entry.value!.roundNumber, + change: entry.value!.change, + ), + mode: InsertMode.insertOrReplace, + ); + } + } + } + }); + + // Add player-match associations await db.batch((b) { for (final match in matches) { for (final p in match.players) { @@ -251,7 +262,7 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { } }); - // Add all player-group associations in batch + // Add player-group associations await db.batch((b) { for (final match in matches) { if (match.group != null) { @@ -300,7 +311,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { final group = await db.groupDao.getGroupById(groupId: groupId); final players = await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? []; - final winner = await db.scoreEntryDao.getWinner(matchId: row.id); return Match( id: row.id, name: row.name, @@ -310,7 +320,6 @@ class MatchDao extends DatabaseAccessor with _$MatchDaoMixin { notes: row.notes ?? '', createdAt: row.createdAt, endedAt: row.endedAt, - winner: winner, ); }), ); diff --git a/lib/data/dao/player_match_dao.dart b/lib/data/dao/player_match_dao.dart index 36a7dbe..b467a1b 100644 --- a/lib/data/dao/player_match_dao.dart +++ b/lib/data/dao/player_match_dao.dart @@ -24,7 +24,7 @@ class PlayerMatchDao extends DatabaseAccessor matchId: matchId, teamId: Value(teamId), ), - mode: InsertMode.insertOrIgnore, + mode: InsertMode.insertOrReplace, ); } diff --git a/lib/data/dao/score_entry_dao.dart b/lib/data/dao/score_entry_dao.dart index cdd42f9..566b9d1 100644 --- a/lib/data/dao/score_entry_dao.dart +++ b/lib/data/dao/score_entry_dao.dart @@ -83,21 +83,21 @@ class ScoreEntryDao extends DatabaseAccessor } /// Retrieves all scores for a specific match. - Future>> getAllMatchScores({ + Future> getAllMatchScores({ required String matchId, }) async { final query = select(scoreEntryTable) ..where((s) => s.matchId.equals(matchId)); final result = await query.get(); - final Map> scoresByPlayer = {}; + final Map scoresByPlayer = {}; for (final row in result) { final score = ScoreEntry( roundNumber: row.roundNumber, score: row.score, change: row.change, ); - scoresByPlayer.putIfAbsent(row.playerId, () => []).add(score); + scoresByPlayer[row.playerId] = score; } return scoresByPlayer; @@ -235,22 +235,29 @@ class ScoreEntryDao extends DatabaseAccessor return rowsAffected > 0; } - // Retrieves the winner of a match based on the highest score. + // Retrieves the winner of a match by looking for a score entry where score + /// is 1. Returns `null` if no player found, else the first with the score. Future getWinner({required String matchId}) async { - final query = select(scoreEntryTable) - ..where((s) => s.matchId.equals(matchId)) - ..orderBy([(s) => OrderingTerm.desc(s.score)]) - ..limit(1); - final result = await query.getSingleOrNull(); + final query = + select(scoreEntryTable).join([ + innerJoin( + db.playerTable, + db.playerTable.id.equalsExp(scoreEntryTable.playerId), + ), + ])..where( + scoreEntryTable.matchId.equals(matchId) & + scoreEntryTable.score.equals(1), + ); - if (result == null) return null; + final result = await query.get(); + if (result.isEmpty) return null; - final player = await db.playerDao.getPlayerById(playerId: result.playerId); + final playerData = result.first.readTable(db.playerTable); return Player( - id: player.id, - name: player.name, - createdAt: player.createdAt, - description: player.description, + id: playerData.id, + name: playerData.name, + createdAt: playerData.createdAt, + description: playerData.description, ); } @@ -295,20 +302,29 @@ class ScoreEntryDao extends DatabaseAccessor return rowsAffected > 0; } - /// Retrieves the looser of a match based on the score 0. + /// Retrieves the looser of a match by looking for a score entry where score + /// is 0. Returns `null` if no player found, else the first with the score. Future getLooser({required String matchId}) async { - final query = select(scoreEntryTable) - ..where((s) => s.matchId.equals(matchId) & s.score.equals(0)); - final result = await query.getSingleOrNull(); + final query = + select(scoreEntryTable).join([ + innerJoin( + db.playerTable, + db.playerTable.id.equalsExp(scoreEntryTable.playerId), + ), + ])..where( + scoreEntryTable.matchId.equals(matchId) & + scoreEntryTable.score.equals(0), + ); - if (result == null) return null; + final result = await query.get(); + if (result.isEmpty) return null; - final player = await db.playerDao.getPlayerById(playerId: result.playerId); + final playerData = result.first.readTable(db.playerTable); return Player( - id: player.id, - name: player.name, - createdAt: player.createdAt, - description: player.description, + id: playerData.id, + name: playerData.name, + createdAt: playerData.createdAt, + description: playerData.description, ); } diff --git a/lib/data/models/game.dart b/lib/data/models/game.dart index 2eeee1e..607db0a 100644 --- a/lib/data/models/game.dart +++ b/lib/data/models/game.dart @@ -1,6 +1,6 @@ import 'package:clock/clock.dart'; -import 'package:uuid/uuid.dart'; import 'package:tallee/core/enums.dart'; +import 'package:uuid/uuid.dart'; class Game { final String id; @@ -33,7 +33,10 @@ class Game { : id = json['id'], createdAt = DateTime.parse(json['createdAt']), name = json['name'], - ruleset = Ruleset.values.firstWhere((e) => e.name == json['ruleset']), + ruleset = Ruleset.values.firstWhere( + (e) => e.name == json['ruleset'], + orElse: () => Ruleset.singleWinner, + ), description = json['description'], color = GameColor.values.firstWhere((e) => e.name == json['color']), icon = json['icon']; @@ -49,4 +52,3 @@ class Game { 'icon': icon, }; } - diff --git a/lib/data/models/match.dart b/lib/data/models/match.dart index 60103de..2ff02d6 100644 --- a/lib/data/models/match.dart +++ b/lib/data/models/match.dart @@ -15,27 +15,25 @@ class Match { final Group? group; final List players; final String notes; - Map> scores; - Player? winner; + Map scores; Match({ - String? id, - DateTime? createdAt, - this.endedAt, required this.name, required this.game, + required this.players, + this.endedAt, this.group, - this.players = const [], this.notes = '', - Map>? scores, - this.winner, + String? id, + DateTime? createdAt, + Map? scores, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? clock.now(), - scores = scores ?? {for (var player in players) player.id: []}; + scores = scores ?? {for (Player p in players) p.id: null}; @override String toString() { - return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, winner: $winner}'; + return 'Match{id: $id, createdAt: $createdAt, endedAt: $endedAt, name: $name, game: $game, group: $group, players: $players, notes: $notes, scores: $scores, mvp: $mvp}'; } /// Creates a Match instance from a JSON object where related objects are @@ -57,7 +55,16 @@ class Match { ), group = null, players = [], - scores = json['scores'], + scores = json['scores'] != null + ? (json['scores'] as Map).map( + (key, value) => MapEntry( + key, + value != null + ? ScoreEntry.fromJson(value as Map) + : null, + ), + ) + : {}, notes = json['notes'] ?? ''; /// Converts the Match instance to a JSON object. Related objects are @@ -71,10 +78,62 @@ class Match { 'gameId': game.id, 'groupId': group?.id, 'playerIds': players.map((player) => player.id).toList(), - 'scores': scores.map( - (playerId, scoreList) => - MapEntry(playerId, scoreList.map((score) => score.toJson()).toList()), - ), + 'scores': scores.map((key, value) => MapEntry(key, value?.toJson())), 'notes': notes, }; + + List get mvp { + if (players.isEmpty || scores.isEmpty) return []; + + switch (game.ruleset) { + case Ruleset.highestScore: + return _getPlayersWithHighestScore(); + + case Ruleset.lowestScore: + return _getPlayersWithLowestScore(); + + case Ruleset.singleWinner: + return _getPlayersWithHighestScore().take(1).toList(); + + case Ruleset.singleLoser: + return _getPlayersWithLowestScore().take(1).toList(); + + case Ruleset.multipleWinners: + return []; + } + } + + List _getPlayersWithHighestScore() { + if (players.isEmpty || scores.values.every((score) => score == null)) { + return []; + } + + final int highestScore = players + .map((player) => scores[player.id]?.score) + .whereType() + .reduce((max, score) => score > max ? score : max); + + return players.where((player) { + final playerScores = scores[player.id]; + if (playerScores == null) return false; + return playerScores.score == highestScore; + }).toList(); + } + + List _getPlayersWithLowestScore() { + if (players.isEmpty || scores.values.every((score) => score == null)) { + return []; + } + + final int lowestScore = players + .map((player) => scores[player.id]?.score) + .whereType() + .reduce((min, score) => score < min ? score : min); + + return players.where((player) { + final playerScore = scores[player.id]; + if (playerScore == null) return false; + return playerScore.score == lowestScore; + }).toList(); + } } diff --git a/lib/data/models/score_entry.dart b/lib/data/models/score_entry.dart index 0f8a8c3..f9c5ff0 100644 --- a/lib/data/models/score_entry.dart +++ b/lib/data/models/score_entry.dart @@ -1,13 +1,14 @@ class ScoreEntry { - int roundNumber = 0; + final int roundNumber; final int score; final int change; - ScoreEntry({ - required this.roundNumber, - required this.score, - required this.change, - }); + ScoreEntry({required this.score, this.roundNumber = 0, this.change = 0}); + + @override + String toString() { + return 'ScoreEntry{roundNumber: $roundNumber, score: $score, change: $change}'; + } ScoreEntry.fromJson(Map json) : roundNumber = json['roundNumber'], diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cec565c..46c780a 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -22,10 +22,11 @@ "days_ago": "vor {count} Tagen", "delete": "Löschen", "delete_all_data": "Alle Daten löschen", - "delete_group": "Diese Gruppe löschen", + "delete_group": "Gruppe löschen", "delete_match": "Spiel löschen", "edit_group": "Gruppe bearbeiten", "edit_match": "Gruppe bearbeiten", + "enter_points": "Punkte eingeben", "enter_results": "Ergebnisse eintragen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", "error_deleting_group": "Fehler beim Löschen der Gruppe, bitte erneut versuchen", @@ -74,6 +75,8 @@ "player_name": "Spieler:innenname", "players": "Spieler:innen", "players_count": "{count} Spieler", + "point": "Punkt", + "points": "Punkte", "privacy_policy": "Datenschutzerklärung", "quick_create": "Schnellzugriff", "recent_matches": "Letzte Spiele", @@ -87,12 +90,14 @@ "save_changes": "Änderungen speichern", "search_for_groups": "Nach Gruppen suchen", "search_for_players": "Nach Spieler:innen suchen", - "select_winner": "Gewinner:in wählen:", + "select_winner": "Gewinner:in wählen", + "select_loser": "Verlierer:in wählen", "selected_players": "Ausgewählte Spieler:innen", "settings": "Einstellungen", "single_loser": "Ein:e Verlierer:in", "single_winner": "Ein:e Gewinner:in", "highest_score": "Höchste Punkte", + "loser": "Verlierer:in", "lowest_score": "Niedrigste Punkte", "multiple_winners": "Mehrere Gewinner:innen", "statistics": "Statistiken", @@ -100,6 +105,7 @@ "successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt", "there_is_no_group_matching_your_search": "Es gibt keine Gruppe, die deiner Suche entspricht", "this_cannot_be_undone": "Dies kann nicht rückgängig gemacht werden.", + "tie": "Unentschieden", "today_at": "Heute um", "undo": "Rückgängig", "unknown_exception": "Unbekannter Fehler (siehe Konsole)", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index aea47f7..a85e1b0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -83,6 +83,9 @@ "@edit_match": { "description": "Button & Appbar label for editing a match" }, + "@enter_points": { + "description": "Label to enter players points" + }, "@enter_results": { "description": "Button text to enter match results" }, @@ -232,6 +235,9 @@ } } }, + "@points": { + "description": "Points label" + }, "@privacy_policy": { "description": "Privacy policy menu item" }, @@ -271,6 +277,9 @@ "@select_winner": { "description": "Label to select the winner" }, + "@select_loser": { + "description": "Label to select the loser" + }, "@selected_players": { "description": "Shows the number of selected players" }, @@ -351,6 +360,7 @@ "delete_match": "Delete Match", "edit_group": "Edit Group", "edit_match": "Edit Match", + "enter_points": "Enter points", "enter_results": "Enter Results", "error_creating_group": "Error while creating group, please try again", "error_deleting_group": "Error while deleting group, please try again", @@ -399,6 +409,8 @@ "player_name": "Player name", "players": "Players", "players_count": "{count} Players", + "point": "Point", + "points": "Points", "privacy_policy": "Privacy Policy", "quick_create": "Quick Create", "recent_matches": "Recent Matches", @@ -411,12 +423,14 @@ "save_changes": "Save Changes", "search_for_groups": "Search for groups", "search_for_players": "Search for players", - "select_winner": "Select Winner:", + "select_winner": "Select Winner", + "select_loser": "Select Loser", "selected_players": "Selected players", "settings": "Settings", "single_loser": "Single Loser", "single_winner": "Single Winner", "highest_score": "Highest Score", + "loser": "Loser", "lowest_score": "Lowest Score", "multiple_winners": "Multiple Winners", "statistics": "Statistics", @@ -424,6 +438,7 @@ "successfully_added_player": "Successfully added player {playerName}", "there_is_no_group_matching_your_search": "There is no group matching your search", "this_cannot_be_undone": "This can't be undone.", + "tie": "Tie", "today_at": "Today at", "undo": "Undo", "unknown_exception": "Unknown Exception (see console)", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index eb8a609..99c9317 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -254,6 +254,12 @@ abstract class AppLocalizations { /// **'Edit Match'** String get edit_match; + /// Label to enter players points + /// + /// In en, this message translates to: + /// **'Enter points'** + String get enter_points; + /// Button text to enter match results /// /// In en, this message translates to: @@ -542,6 +548,18 @@ abstract class AppLocalizations { /// **'{count} Players'** String players_count(int count); + /// No description provided for @point. + /// + /// In en, this message translates to: + /// **'Point'** + String get point; + + /// Points label + /// + /// In en, this message translates to: + /// **'Points'** + String get points; + /// Privacy policy menu item /// /// In en, this message translates to: @@ -617,9 +635,15 @@ abstract class AppLocalizations { /// Label to select the winner /// /// In en, this message translates to: - /// **'Select Winner:'** + /// **'Select Winner'** String get select_winner; + /// Label to select the loser + /// + /// In en, this message translates to: + /// **'Select Loser'** + String get select_loser; + /// Shows the number of selected players /// /// In en, this message translates to: @@ -650,6 +674,12 @@ abstract class AppLocalizations { /// **'Highest Score'** String get highest_score; + /// No description provided for @loser. + /// + /// In en, this message translates to: + /// **'Loser'** + String get loser; + /// No description provided for @lowest_score. /// /// In en, this message translates to: @@ -692,6 +722,12 @@ abstract class AppLocalizations { /// **'This can\'t be undone.'** String get this_cannot_be_undone; + /// No description provided for @tie. + /// + /// In en, this message translates to: + /// **'Tie'** + String get tie; + /// Date format for today /// /// 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 501f9c6..51b4c62 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -79,7 +79,7 @@ class AppLocalizationsDe extends AppLocalizations { String get delete_all_data => 'Alle Daten löschen'; @override - String get delete_group => 'Diese Gruppe löschen'; + String get delete_group => 'Gruppe löschen'; @override String get delete_match => 'Spiel löschen'; @@ -90,6 +90,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get edit_match => 'Gruppe bearbeiten'; + @override + String get enter_points => 'Punkte eingeben'; + @override String get enter_results => 'Ergebnisse eintragen'; @@ -240,6 +243,12 @@ class AppLocalizationsDe extends AppLocalizations { return '$count Spieler'; } + @override + String get point => 'Punkt'; + + @override + String get points => 'Punkte'; + @override String get privacy_policy => 'Datenschutzerklärung'; @@ -281,7 +290,10 @@ class AppLocalizationsDe extends AppLocalizations { String get search_for_players => 'Nach Spieler:innen suchen'; @override - String get select_winner => 'Gewinner:in wählen:'; + String get select_winner => 'Gewinner:in wählen'; + + @override + String get select_loser => 'Verlierer:in wählen'; @override String get selected_players => 'Ausgewählte Spieler:innen'; @@ -298,6 +310,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get highest_score => 'Höchste Punkte'; + @override + String get loser => 'Verlierer:in'; + @override String get lowest_score => 'Niedrigste Punkte'; @@ -323,6 +338,9 @@ class AppLocalizationsDe extends AppLocalizations { String get this_cannot_be_undone => 'Dies kann nicht rückgängig gemacht werden.'; + @override + String get tie => 'Unentschieden'; + @override String get today_at => 'Heute um'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index cdebc69..2b42e47 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -90,6 +90,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get edit_match => 'Edit Match'; + @override + String get enter_points => 'Enter points'; + @override String get enter_results => 'Enter Results'; @@ -240,6 +243,12 @@ class AppLocalizationsEn extends AppLocalizations { return '$count Players'; } + @override + String get point => 'Point'; + + @override + String get points => 'Points'; + @override String get privacy_policy => 'Privacy Policy'; @@ -281,7 +290,10 @@ class AppLocalizationsEn extends AppLocalizations { String get search_for_players => 'Search for players'; @override - String get select_winner => 'Select Winner:'; + String get select_winner => 'Select Winner'; + + @override + String get select_loser => 'Select Loser'; @override String get selected_players => 'Selected players'; @@ -298,6 +310,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get highest_score => 'Highest Score'; + @override + String get loser => 'Loser'; + @override String get lowest_score => 'Lowest Score'; @@ -322,6 +337,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get this_cannot_be_undone => 'This can\'t be undone.'; + @override + String get tie => 'Tie'; + @override String get today_at => 'Today at'; 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 d5ac6a4..f88e2db 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 @@ -148,7 +148,7 @@ class _CreateGroupViewState extends State { final groupName = _groupNameController.text.trim(); final success = await db.groupDao.addGroup( - group: Group(name: groupName, description: '', members: selectedPlayers), + group: Group(name: groupName, members: selectedPlayers), ); return success; diff --git a/lib/presentation/views/main_menu/group_view/group_detail_view.dart b/lib/presentation/views/main_menu/group_view/group_detail_view.dart index fc1ab99..92c3bba 100644 --- a/lib/presentation/views/main_menu/group_view/group_detail_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_detail_view.dart @@ -255,28 +255,37 @@ class _GroupDetailViewState extends State { /// Determines the best player in the group based on match wins String _getBestPlayer(List matches) { - final bestPlayerCounts = {}; + final mvpCounts = {}; - // Count wins for each player for (var match in matches) { - if (match.winner != null && - _group.members.any((m) => m.id == match.winner?.id)) { - print(match.winner); - bestPlayerCounts.update( - match.winner!, - (value) => value + 1, - ifAbsent: () => 1, - ); + final mvps = match.mvp; + for (final mvpPlayer in mvps) { + if (_group.members.any((m) => m.id == mvpPlayer.id)) { + mvpCounts.update(mvpPlayer, (value) => value + 1, ifAbsent: () => 1); + } } } - // Sort players by win count - final sortedPlayers = bestPlayerCounts.entries.toList() + final sortedMvps = mvpCounts.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); - // Get the best player - bestPlayer = sortedPlayers.isNotEmpty ? sortedPlayers.first.key.name : '-'; + if (sortedMvps.isEmpty) { + return '-'; + } - return bestPlayer; + // Check if there are multiple players with the same value + final highestMvpCount = sortedMvps.first.value; + final topPlayers = sortedMvps + .where((entry) => entry.value == highestMvpCount) + .toList(); + switch (topPlayers.length) { + case 0: + return '-'; + case 1: + return topPlayers.first.key.name; + default: + final loc = AppLocalizations.of(context); + return loc.tie; + } } } 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 b091541..c8a9398 100644 --- a/lib/presentation/views/main_menu/group_view/group_view.dart +++ b/lib/presentation/views/main_menu/group_view/group_view.dart @@ -36,7 +36,7 @@ class _GroupViewState extends State { Group( name: 'Skeleton Group', description: '', - members: List.filled(6, Player(name: 'Skeleton Player', description: '')), + members: List.filled(6, Player(name: 'Skeleton Player')), ), ); diff --git a/lib/presentation/views/main_menu/home_view.dart b/lib/presentation/views/main_menu/home_view.dart index 09cec54..321f12b 100644 --- a/lib/presentation/views/main_menu/home_view.dart +++ b/lib/presentation/views/main_menu/home_view.dart @@ -43,21 +43,41 @@ class _HomeViewState extends State { Match( name: 'Skeleton Match', game: Game( - name: '', + name: 'Skeleton Game', ruleset: Ruleset.singleWinner, - description: '', + description: 'This is a skeleton game description.', color: GameColor.blue, icon: '', ), group: Group( name: 'Skeleton Group', - description: '', + description: 'This is a skeleton group description.', members: [ - Player(name: 'Skeleton Player 1', description: ''), - Player(name: 'Skeleton Player 2', description: ''), + Player( + name: + 'Skeleton Player 1' + '', + ), + Player( + name: + 'Skeleton Player 2' + '', + ), ], ), - notes: '', + notes: 'These are skeleton notes.', + players: [ + Player( + name: + 'Skeleton Player 1' + '', + ), + Player( + name: + 'Skeleton Player 2' + '', + ), + ], ), ); @@ -125,7 +145,11 @@ class _HomeViewState extends State { MatchResultView(match: match), ), ); - await updatedWinnerInRecentMatches(match.id); + await loadRecentMatches(); + + setState(() { + print('loaded'); + }); }, ), ) @@ -224,15 +248,12 @@ class _HomeViewState extends State { }); } - /// Updates the winner information for a specific match in the recent matches list. - Future updatedWinnerInRecentMatches(String matchId) async { + Future loadRecentMatches() async { final db = Provider.of(context, listen: false); - final winner = await db.scoreEntryDao.getWinner(matchId: matchId); - final matchIndex = recentMatches.indexWhere((match) => match.id == matchId); - if (matchIndex != -1) { - setState(() { - recentMatches[matchIndex].winner = winner; - }); - } + final matches = await db.matchDao.getAllMatches(); + recentMatches = + (matches..sort((a, b) => b.createdAt.compareTo(a.createdAt))) + .take(2) + .toList(); } } diff --git a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart index d4d7f4d..51512f9 100644 --- a/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart +++ b/lib/presentation/views/main_menu/match_view/create_match/choose_game_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.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/game.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; @@ -13,14 +13,14 @@ class ChooseGameView extends StatefulWidget { const ChooseGameView({ super.key, required this.games, - required this.initialGameIndex, + required this.initialGameId, }); /// A list of tuples containing the game name, description and ruleset - final List<(String, String, Ruleset)> games; + final List games; - /// The index of the initially selected game - final int initialGameIndex; + /// The id of the initially selected game + final String initialGameId; @override State createState() => _ChooseGameViewState(); @@ -31,11 +31,11 @@ class _ChooseGameViewState extends State { final TextEditingController searchBarController = TextEditingController(); /// Currently selected game index - late int selectedGameIndex; + late String selectedGameId; @override void initState() { - selectedGameIndex = widget.initialGameIndex; + selectedGameId = widget.initialGameId; super.initState(); } @@ -49,7 +49,13 @@ class _ChooseGameViewState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back_ios), onPressed: () { - Navigator.of(context).pop(selectedGameIndex); + Navigator.of(context).pop( + selectedGameId == '' + ? null + : widget.games.firstWhere( + (game) => game.id == selectedGameId, + ), + ); }, ), title: Text(loc.choose_game), @@ -62,7 +68,7 @@ class _ChooseGameViewState extends State { if (didPop) { return; } - Navigator.of(context).pop(selectedGameIndex); + Navigator.of(context).pop(widget.initialGameId); }, child: Column( children: [ @@ -79,19 +85,19 @@ class _ChooseGameViewState extends State { itemCount: widget.games.length, itemBuilder: (BuildContext context, int index) { return TitleDescriptionListTile( - title: widget.games[index].$1, - description: widget.games[index].$2, + title: widget.games[index].name, + description: widget.games[index].description, badgeText: translateRulesetToString( - widget.games[index].$3, + widget.games[index].ruleset, context, ), - isHighlighted: selectedGameIndex == index, + isHighlighted: selectedGameId == widget.games[index].id, onPressed: () async { setState(() { - if (selectedGameIndex == index) { - selectedGameIndex = -1; + if (selectedGameId != widget.games[index].id) { + selectedGameId = widget.games[index].id; } else { - selectedGameIndex = index; + selectedGameId = ''; } }); }, 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 950b3a8..1a04c78 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 @@ -20,7 +20,9 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart'; class CreateMatchView extends StatefulWidget { /// A view that allows creating a new match - /// [onWinnerChanged]: Optional callback invoked when the winner is changed + /// - [onWinnerChanged]: Optional callback invoked when the winner is changed + /// - [matchToEdit]: An optional match to prefill the fields for editing. + /// - [onMatchUpdated]: Optional callback invoked when the match is updated (only in const CreateMatchView({ super.key, this.onWinnerChanged, @@ -28,13 +30,11 @@ class CreateMatchView extends StatefulWidget { this.onMatchUpdated, }); - /// Optional callback invoked when the winner is changed final VoidCallback? onWinnerChanged; - /// Optional callback invoked when the match is updated final void Function(Match)? onMatchUpdated; - /// An optional match to prefill the fields + /// An optional match to prefill the fields for editing. final Match? matchToEdit; @override @@ -50,20 +50,12 @@ class _CreateMatchViewState extends State { /// Hint text for the match name input field String? hintText; - /// List of all groups from the database List groupsList = []; - - /// List of all players from the database List playerList = []; + List gamesList = []; - /// The currently selected group Group? selectedGroup; - - /// The index of the currently selected game in [games] to mark it in - /// the [ChooseGameView] - int selectedGameIndex = -1; - - /// The currently selected players + Game? selectedGame; List selectedPlayers = []; /// GlobalKey for ScaffoldMessenger to show snackbars @@ -81,12 +73,14 @@ class _CreateMatchViewState extends State { Future.wait([ db.groupDao.getAllGroups(), db.playerDao.getAllPlayers(), + db.gameDao.getAllGames(), ]).then((result) async { groupsList = result[0] as List; playerList = result[1] as List; + gamesList = (result[2] as List); // If a match is provided, prefill the fields - if (widget.matchToEdit != null) { + if (isEditMode()) { prefillMatchDetails(); } }); @@ -105,20 +99,11 @@ class _CreateMatchViewState extends State { hintText ??= loc.match_name; } - List<(String, String, Ruleset)> games = [ - ('Example Game 1', 'This is a description', Ruleset.lowestScore), - ('Example Game 2', '', Ruleset.singleWinner), - ]; - @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); - final buttonText = widget.matchToEdit != null - ? loc.save_changes - : loc.create_match; - final viewTitle = widget.matchToEdit != null - ? loc.edit_match - : loc.create_new_match; + final buttonText = isEditMode() ? loc.save_changes : loc.create_match; + final viewTitle = isEditMode() ? loc.edit_match : loc.create_new_match; return ScaffoldMessenger( key: _scaffoldMessengerKey, @@ -140,21 +125,21 @@ class _CreateMatchViewState extends State { ), ChooseTile( title: loc.game, - trailingText: selectedGameIndex == -1 - ? loc.none - : games[selectedGameIndex].$1, + trailingText: selectedGame == null + ? loc.none_group + : selectedGame!.name, onPressed: () async { - selectedGameIndex = await Navigator.of(context).push( + selectedGame = await Navigator.of(context).push( adaptivePageRoute( builder: (context) => ChooseGameView( - games: games, - initialGameIndex: selectedGameIndex, + games: gamesList, + initialGameId: selectedGame?.id ?? '', ), ), ); setState(() { - if (selectedGameIndex != -1) { - hintText = games[selectedGameIndex].$1; + if (selectedGame != null) { + hintText = selectedGame!.name; } else { hintText = loc.match_name; } @@ -225,6 +210,10 @@ class _CreateMatchViewState extends State { ); } + bool isEditMode() { + return widget.matchToEdit != null; + } + /// Determines whether the "Create Match" button should be enabled. /// /// Returns `true` if: @@ -232,7 +221,7 @@ class _CreateMatchViewState extends State { /// - Either a group is selected OR at least 2 players are selected bool _enableCreateGameButton() { return (selectedGroup != null || - (selectedPlayers.length > 1) && selectedGameIndex != -1); + (selectedPlayers.length > 1) && selectedGame != null); } // If a match was provided to the view, it updates the match in the database @@ -240,7 +229,7 @@ class _CreateMatchViewState extends State { // If no match was provided, it creates a new match in the database and // navigates to the MatchResultView for the newly created match. void buttonNavigation(BuildContext context) async { - if (widget.matchToEdit != null) { + if (isEditMode()) { await updateMatch(); if (context.mounted) { Navigator.pop(context); @@ -266,9 +255,6 @@ class _CreateMatchViewState extends State { /// Updates attributes of the existing match in the database based on the /// changes made in the edit view. Future updateMatch() async { - //TODO: Remove when Games implemented - final tempGame = await getTemporaryGame(); - final updatedMatch = Match( id: widget.matchToEdit!.id, name: _matchNameController.text.isEmpty @@ -276,8 +262,7 @@ class _CreateMatchViewState extends State { : _matchNameController.text.trim(), group: selectedGroup, players: selectedPlayers, - game: tempGame, - winner: widget.matchToEdit!.winner, + game: widget.matchToEdit!.game, createdAt: widget.matchToEdit!.createdAt, endedAt: widget.matchToEdit!.endedAt, notes: widget.matchToEdit!.notes, @@ -314,9 +299,6 @@ class _CreateMatchViewState extends State { matchId: widget.matchToEdit!.id, playerId: player.id, ); - if (widget.matchToEdit!.winner?.id == player.id) { - updatedMatch.winner = null; - } } } @@ -326,8 +308,6 @@ class _CreateMatchViewState extends State { // Creates a new match and adds it to the database. // Returns the created match. Future createMatch() async { - final tempGame = await getTemporaryGame(); - Match match = Match( name: _matchNameController.text.isEmpty ? (hintText ?? '') @@ -335,35 +315,18 @@ class _CreateMatchViewState extends State { createdAt: DateTime.now(), group: selectedGroup, players: selectedPlayers, - game: tempGame, + game: selectedGame!, ); await db.matchDao.addMatch(match: match); return match; } - // TODO: Remove when games fully implemented - Future getTemporaryGame() async { - Game? game; - - final selectedGame = games[selectedGameIndex]; - game = Game( - name: selectedGame.$1, - description: selectedGame.$2, - ruleset: selectedGame.$3, - color: GameColor.blue, - icon: '', - ); - - await db.gameDao.addGame(game: game); - return game; - } - // If a match was provided to the view, this method prefills the input fields void prefillMatchDetails() { final match = widget.matchToEdit!; _matchNameController.text = match.name; selectedPlayers = match.players; - selectedGameIndex = 0; + selectedGame = match.game; if (match.group != null) { selectedGroup = match.group; diff --git a/lib/presentation/views/main_menu/match_view/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart index 100674f..2117b77 100644 --- a/lib/presentation/views/main_menu/match_view/match_detail_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -170,37 +170,7 @@ class _MatchDetailViewState extends State { vertical: 4, horizontal: 8, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - /// TODO: Implement different ruleset results display - if (match.winner != null) ...[ - Text( - loc.winner, - style: const TextStyle( - fontSize: 16, - color: CustomTheme.textColor, - ), - ), - Text( - match.winner!.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: CustomTheme.primaryColor, - ), - ), - ] else ...[ - Text( - loc.no_results_entered_yet, - style: const TextStyle( - fontSize: 14, - color: CustomTheme.textColor, - ), - ), - ], - ], - ), + child: getResultWidget(loc), ), ), ], @@ -227,7 +197,7 @@ class _MatchDetailViewState extends State { text: loc.enter_results, icon: Icons.emoji_events, onPressed: () async { - match.winner = await Navigator.push( + await Navigator.push( context, adaptivePageRoute( fullscreenDialog: true, @@ -259,4 +229,108 @@ class _MatchDetailViewState extends State { }); widget.onMatchUpdate.call(); } + + /// Returns the widget to be displayed in the result [InfoTile] + Widget getResultWidget(AppLocalizations loc) { + if (isSingleRowResult()) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: getSingleResultRow(loc), + ); + } else { + return getScoreResultWidget(loc); + } + } + + /// Returns the result row for single winner/loser rulesets or a placeholder + /// if no result is entered yet + List getSingleResultRow(AppLocalizations loc) { + // Single Winner + if (match.mvp.isNotEmpty && match.game.ruleset == Ruleset.singleWinner) { + return [ + Text( + loc.winner, + style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), + ), + Text( + match.mvp.first.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ]; + // Single Loser + } else if (match.game.ruleset == Ruleset.singleLoser) { + return [ + Text( + loc.loser, + style: const TextStyle(fontSize: 16, color: CustomTheme.textColor), + ), + Text( + match.mvp.first.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ]; + // No result entered yet + } else { + return [ + Text( + loc.no_results_entered_yet, + style: const TextStyle(fontSize: 14, color: CustomTheme.textColor), + ), + ]; + } + } + + /// Returns the result widget for scores + Widget getScoreResultWidget(AppLocalizations loc) { + List<(String, int)> playerScores = []; + for (var player in match.players) { + int score = match.scores[player.id]?.score ?? 0; + playerScores.add((player.name, score)); + } + if (widget.match.game.ruleset == Ruleset.highestScore) { + playerScores.sort((a, b) => b.$2.compareTo(a.$2)); + } else if (widget.match.game.ruleset == Ruleset.lowestScore) { + playerScores.sort((a, b) => a.$2.compareTo(b.$2)); + } + + return Column( + children: [ + for (var score in playerScores) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + score.$1, + style: const TextStyle( + fontSize: 16, + color: CustomTheme.textColor, + ), + ), + Text( + getPointLabel(loc, score.$2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), + ], + ), + ], + ); + } + + // Returns if the result can be displayed in a single row + bool isSingleRowResult() { + return match.game.ruleset == Ruleset.singleWinner || + match.game.ruleset == Ruleset.singleLoser; + } } diff --git a/lib/presentation/views/main_menu/match_view/match_result_view.dart b/lib/presentation/views/main_menu/match_view/match_result_view.dart index d677e43..8b41920 100644 --- a/lib/presentation/views/main_menu/match_view/match_result_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_result_view.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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/score_entry.dart'; import 'package:tallee/l10n/generated/app_localizations.dart'; +import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; import 'package:tallee/presentation/widgets/tiles/custom_radio_list_tile.dart'; +import 'package:tallee/presentation/widgets/tiles/score_list_tile.dart'; class MatchResultView extends StatefulWidget { /// A view that allows selecting and saving the winner of a match @@ -26,30 +30,61 @@ class MatchResultView extends StatefulWidget { class _MatchResultViewState extends State { late final AppDatabase db; + late final Ruleset ruleset; + /// List of all players who participated in the match late final List allPlayers; + /// List of text controllers for score entry, one for each player + late final List controller; + + late bool canSave; + /// Currently selected winner player Player? _selectedPlayer; @override void initState() { db = Provider.of(context, listen: false); + ruleset = widget.match.game.ruleset; + canSave = !rulesetSupportsScoreEntry(); allPlayers = widget.match.players; allPlayers.sort((a, b) => a.name.compareTo(b.name)); - if (widget.match.winner != null) { - _selectedPlayer = allPlayers.firstWhere( - (p) => p.id == widget.match.winner!.id, - ); + controller = List.generate( + allPlayers.length, + (index) => TextEditingController()..addListener(() => onTextEnter()), + ); + + if (widget.match.mvp.isNotEmpty) { + if (rulesetSupportsWinnerSelection()) { + _selectedPlayer = allPlayers.firstWhere( + (p) => p.id == widget.match.mvp.first.id, + ); + } else if (rulesetSupportsScoreEntry()) { + for (int i = 0; i < allPlayers.length; i++) { + final scoreList = widget.match.scores[allPlayers[i].id]; + final score = scoreList?.score ?? 0; + controller[i].text = score.toString(); + } + } + super.initState(); } - super.initState(); + } + + @override + void dispose() { + for (final c in controller) { + c.dispose(); + } + super.dispose(); } @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); + return Scaffold( backgroundColor: CustomTheme.backgroundColor, appBar: AppBar( @@ -85,67 +120,169 @@ class _MatchResultViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - loc.select_winner, + '${getTitleForRuleset(loc)}:', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 10), - Expanded( - child: RadioGroup( - groupValue: _selectedPlayer, - onChanged: (Player? value) async { - setState(() { - _selectedPlayer = value; - }); - await _handleWinnerSaving(); - }, - child: ListView.builder( + if (rulesetSupportsWinnerSelection()) + Expanded( + child: RadioGroup( + groupValue: _selectedPlayer, + onChanged: (Player? value) async { + setState(() { + _selectedPlayer = value; + }); + }, + child: ListView.builder( + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return CustomRadioListTile( + text: allPlayers[index].name, + value: allPlayers[index], + onContainerTap: (value) async { + setState(() { + // Check if the already selected player is the same as the newly tapped player. + if (_selectedPlayer == value) { + // If yes deselected the player by setting it to null. + _selectedPlayer = null; + } else { + // If no assign the newly tapped player to the selected player. + (_selectedPlayer = value); + } + }); + }, + ); + }, + ), + ), + ), + if (rulesetSupportsScoreEntry()) + Expanded( + child: ListView.separated( itemCount: allPlayers.length, itemBuilder: (context, index) { - return CustomRadioListTile( + return ScoreListTile( text: allPlayers[index].name, - value: allPlayers[index], - onContainerTap: (value) async { - setState(() { - // Check if the already selected player is the same as the newly tapped player. - if (_selectedPlayer == value) { - // If yes deselected the player by setting it to null. - _selectedPlayer = null; - } else { - // If no assign the newly tapped player to the selected player. - (_selectedPlayer = value); - } - }); - await _handleWinnerSaving(); - }, + controller: controller[index], + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider(indent: 20), ); }, ), ), - ), ], ), ), ), + CustomWidthButton( + text: loc.save_changes, + sizeRelativeToWidth: 0.95, + onPressed: canSave + ? () async { + final ending = DateTime.now(); + await db.matchDao.updateMatchEndedAt( + matchId: widget.match.id, + endedAt: ending, + ); + await _handleSaving(); + if (!context.mounted) return; + Navigator.of(context).pop(_selectedPlayer); + } + : null, + ), ], ), ), ); } + /// Updated [canSave] everytime a text is entered in one of the score entry fields. + void onTextEnter() { + if (rulesetSupportsScoreEntry()) { + setState(() { + canSave = controller.every((c) => c.text.isNotEmpty); + }); + } + } + /// Handles saving or removing the winner in the database /// based on the current selection. - Future _handleWinnerSaving() async { + Future _handleSaving() async { + if (ruleset == Ruleset.singleWinner) { + await _handleWinner(); + } else if (ruleset == Ruleset.singleLoser) { + await _handleLoser(); + } else if (ruleset == Ruleset.lowestScore || + ruleset == Ruleset.highestScore) { + await _handleScores(); + } + + widget.onWinnerChanged?.call(); + } + + /// Handles saving or removing the winner in the database. + Future _handleWinner() async { if (_selectedPlayer == null) { - await db.scoreEntryDao.removeWinner(matchId: widget.match.id); + return await db.scoreEntryDao.removeWinner(matchId: widget.match.id); } else { - await db.scoreEntryDao.setWinner( + return await db.scoreEntryDao.setWinner( matchId: widget.match.id, playerId: _selectedPlayer!.id, ); } - widget.onWinnerChanged?.call(); + } + + /// Handles saving or removing the loser in the database. + Future _handleLoser() async { + if (_selectedPlayer == null) { + return await db.scoreEntryDao.removeLooser(matchId: widget.match.id); + } else { + return await db.scoreEntryDao.setLooser( + matchId: widget.match.id, + playerId: _selectedPlayer!.id, + ); + } + } + + /// Handles saving the scores for each player in the database. + Future _handleScores() async { + for (int i = 0; i < allPlayers.length; i++) { + var text = controller[i].text; + if (text.isEmpty) { + text = '0'; + } + final score = int.parse(text); + await db.scoreEntryDao.addScore( + matchId: widget.match.id, + playerId: allPlayers[i].id, + entry: ScoreEntry(roundNumber: 0, score: score, change: 0), + ); + } + } + + String getTitleForRuleset(AppLocalizations loc) { + switch (ruleset) { + case Ruleset.singleWinner: + return loc.select_winner; + case Ruleset.singleLoser: + return loc.select_loser; + default: + return loc.enter_points; + } + } + + bool rulesetSupportsWinnerSelection() { + return ruleset == Ruleset.singleWinner || ruleset == Ruleset.singleLoser; + } + + bool rulesetSupportsScoreEntry() { + return ruleset == Ruleset.lowestScore || ruleset == Ruleset.highestScore; } } 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 1a202c4..2fb36e7 100644 --- a/lib/presentation/views/main_menu/match_view/match_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_view.dart @@ -37,20 +37,16 @@ class _MatchViewState extends State { Match( name: 'Skeleton match name', game: Game( - name: '', + name: 'Game name', ruleset: Ruleset.singleWinner, - description: '', color: GameColor.blue, icon: '', ), group: Group( name: 'Group name', - description: '', - members: List.filled(5, Player(name: 'Player', description: '')), + members: List.filled(5, Player(name: 'Player')), ), - winner: Player(name: 'Player', description: ''), - players: [Player(name: 'Player', description: '')], - notes: '', + players: [Player(name: 'Player')], ), ); @@ -116,7 +112,7 @@ class _MatchViewState extends State { Positioned( bottom: MediaQuery.paddingOf(context).bottom + 20, child: MainMenuButton( - text: 'Spiel erstellen', + text: loc.create_match, icon: RpgAwesome.clovers_card, onPressed: () async { Navigator.push( diff --git a/lib/presentation/views/main_menu/statistics_view.dart b/lib/presentation/views/main_menu/statistics_view.dart index 221ffee..98a8e1d 100644 --- a/lib/presentation/views/main_menu/statistics_view.dart +++ b/lib/presentation/views/main_menu/statistics_view.dart @@ -152,8 +152,8 @@ class _StatisticsViewState extends State { // Getting the winners for (var match in matches) { - final winner = match.winner; - if (winner != null) { + final mvps = match.mvp; + for (var winner in mvps) { final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id); // -1 means winner not found in winCounts if (index != -1) { @@ -179,8 +179,7 @@ class _StatisticsViewState extends State { final playerId = winCounts[i].$1.id; final player = players.firstWhere( (p) => p.id == playerId, - orElse: () => - Player(id: playerId, name: loc.not_available, description: ''), + orElse: () => Player(id: playerId, name: loc.not_available), ); winCounts[i] = (player, winCounts[i].$2); } diff --git a/lib/presentation/widgets/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 0aa6653..0fc8ea0 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -63,7 +63,7 @@ class _PlayerSelectionState extends State { /// Skeleton data used while loading players. late final List skeletonData = List.filled( 7, - Player(name: 'Player 0', description: ''), + Player(name: 'Player 0'), ); @override diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index eaf6a4f..f7585d6 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.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/widgets/tiles/text_icon_tile.dart'; @@ -44,7 +45,6 @@ class _MatchTileState extends State { Widget build(BuildContext context) { final match = widget.match; final group = match.group; - final winner = match.winner; final players = [...match.players] ..sort((a, b) => a.name.compareTo(b.name)); final loc = AppLocalizations.of(context); @@ -79,8 +79,7 @@ class _MatchTileState extends State { ], ), - const SizedBox(height: 8), - + // Group Info if (group != null) ...[ Row( children: [ @@ -95,7 +94,7 @@ class _MatchTileState extends State { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 4), ] else if (widget.compact) ...[ Row( children: [ @@ -110,10 +109,69 @@ class _MatchTileState extends State { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 6), + ] else ...[ + const SizedBox(height: 8), ], - if (winner != null) ...[ + // Game + Ruleset Badge + if (!widget.compact) + IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Game + Container( + decoration: BoxDecoration( + color: CustomTheme.primaryColor.withAlpha(230), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Text( + match.game.name, + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + // Ruleset + Container( + decoration: BoxDecoration( + color: CustomTheme.primaryColor.withAlpha(140), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Text( + translateRulesetToString(match.game.ruleset, context), + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Winner / In Progress Info + if (match.mvp.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( vertical: 8, @@ -129,15 +187,11 @@ class _MatchTileState extends State { ), child: Row( children: [ - const Icon( - Icons.emoji_events, - size: 20, - color: Colors.amber, - ), + getMvpIcon(), const SizedBox(width: 8), Expanded( child: Text( - '${loc.winner}: ${winner.name}', + getMvpText(loc), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -189,6 +243,7 @@ class _MatchTileState extends State { const SizedBox(height: 12), ], + // Players List if (players.isNotEmpty && widget.compact == false) ...[ Text( loc.players, @@ -234,4 +289,44 @@ class _MatchTileState extends State { return '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(dateTime)}'; } } + + String getMvpText(AppLocalizations loc) { + if (widget.match.mvp.isEmpty) return ''; + final ruleset = widget.match.game.ruleset; + + if (ruleset == Ruleset.singleWinner) { + return '${loc.winner}: ${widget.match.mvp.first.name}'; + } else if (ruleset == Ruleset.singleLoser) { + return '${loc.loser}: ${widget.match.mvp.first.name}'; + } else if (ruleset == Ruleset.highestScore || + ruleset == Ruleset.lowestScore) { + final mvp = widget.match.mvp; + final mvpScore = widget.match.scores[mvp.first.id]?.score ?? 0; + final mvpNames = mvp.map((player) => player.name).join(', '); + + return '${loc.winner}: $mvpNames (${getPointLabel(loc, mvpScore)})'; + } + return '${loc.winner}: n.A.'; + } + + Icon getMvpIcon() { + const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + + switch (widget.match.game.ruleset) { + case Ruleset.singleWinner: + return const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + case Ruleset.singleLoser: + return const Icon( + Icons.sentiment_dissatisfied_outlined, + size: 20, + color: Colors.blue, + ); + case Ruleset.lowestScore: + return const Icon(Icons.arrow_downward, size: 20, color: Colors.orange); + case Ruleset.highestScore: + return const Icon(Icons.arrow_upward, size: 20, color: Colors.green); + default: + return const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + } + } } diff --git a/lib/presentation/widgets/tiles/score_list_tile.dart b/lib/presentation/widgets/tiles/score_list_tile.dart new file mode 100644 index 0000000..52103fa --- /dev/null +++ b/lib/presentation/widgets/tiles/score_list_tile.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tallee/core/custom_theme.dart'; +import 'package:tallee/l10n/generated/app_localizations.dart'; + +class ScoreListTile extends StatelessWidget { + /// A custom list tile widget that has a text field for inputting a score. + /// - [text]: The leading text to be displayed. + /// - [controller]: The controller for the text field to input the score. + const ScoreListTile({ + super.key, + required this.text, + required this.controller, + }); + + /// The text to display next to the radio button. + final String text; + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration(color: CustomTheme.boxColor), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + text, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500), + ), + SizedBox( + width: 100, + height: 40, + child: TextField( + controller: controller, + keyboardType: TextInputType.number, + maxLength: 4, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: CustomTheme.textColor, + ), + cursorColor: CustomTheme.textColor, + decoration: InputDecoration( + hintText: loc.points, + counterText: '', + filled: true, + fillColor: CustomTheme.onBoxColor, + contentPadding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 0, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: CustomTheme.textColor.withAlpha(100), + width: 2, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: CustomTheme.primaryColor, + width: 2, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/data_transfer_service.dart b/lib/services/data_transfer_service.dart index a0fd57b..daf4768 100644 --- a/lib/services/data_transfer_service.dart +++ b/lib/services/data_transfer_service.dart @@ -12,6 +12,7 @@ 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/data/models/score_entry.dart'; import 'package:tallee/data/models/team.dart'; class DataTransferService { @@ -36,59 +37,12 @@ class DataTransferService { final games = await db.gameDao.getAllGames(); final teams = await db.teamDao.getAllTeams(); - // Construct a JSON representation of the data in normalized format final Map jsonMap = { - 'players': players.map((p) => p.toJson()).toList(), - 'games': games.map((g) => g.toJson()).toList(), - 'groups': groups - .map( - (g) => { - 'id': g.id, - 'name': g.name, - 'description': g.description, - 'createdAt': g.createdAt.toIso8601String(), - 'memberIds': (g.members).map((m) => m.id).toList(), - }, - ) - .toList(), - 'teams': teams - .map( - (t) => { - 'id': t.id, - 'name': t.name, - 'createdAt': t.createdAt.toIso8601String(), - 'memberIds': (t.members).map((m) => m.id).toList(), - }, - ) - .toList(), - 'matches': matches - .map( - (m) => { - 'id': m.id, - 'name': m.name, - 'createdAt': m.createdAt.toIso8601String(), - 'endedAt': m.endedAt?.toIso8601String(), - 'gameId': m.game.id, - 'groupId': m.group?.id, - 'playerIds': m.players.map((p) => p.id).toList(), - 'scores': m.scores.map( - (playerId, scores) => MapEntry( - playerId, - scores - .map( - (s) => { - 'roundNumber': s.roundNumber, - 'score': s.score, - 'change': s.change, - }, - ) - .toList(), - ), - ), - 'notes': m.notes, - }, - ) - .toList(), + 'players': players.map((player) => player.toJson()).toList(), + 'games': games.map((game) => game.toJson()).toList(), + 'groups': groups.map((group) => group.toJson()).toList(), + 'teams': teams.map((team) => team.toJson()).toList(), + 'matches': matches.map((match) => match.toJson()).toList(), }; return json.encode(jsonMap); @@ -105,7 +59,7 @@ class DataTransferService { ) async { try { final bytes = Uint8List.fromList(utf8.encode(jsonString)); - final path = await FilePicker.platform.saveFile( + final path = await FilePicker.saveFile( fileName: '$fileName.json', bytes: bytes, ); @@ -126,7 +80,7 @@ class DataTransferService { static Future importData(BuildContext context) async { final db = Provider.of(context, listen: false); - final path = await FilePicker.platform.pickFiles( + final path = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['json'], ); @@ -284,6 +238,15 @@ class DataTransferService { ? DateTime.parse(map['endedAt'] as String) : null; final notes = map['notes'] as String? ?? ''; + final scoresJson = map['scores'] as Map? ?? {}; + final scores = scoresJson.map( + (key, value) => MapEntry( + key, + value != null + ? ScoreEntry.fromJson(value as Map) + : null, + ), + ); // Link attributes to objects final game = gamesMap[gameId] ?? getFallbackGame(); @@ -305,6 +268,7 @@ class DataTransferService { createdAt: createdAt, endedAt: endedAt, notes: notes, + scores: scores, ); }).toList(); } diff --git a/pubspec.yaml b/pubspec.yaml index 2de3b32..d730b55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,18 +7,18 @@ environment: sdk: ^3.8.1 dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter clock: ^1.1.2 cupertino_icons: ^1.0.6 drift: ^2.27.0 drift_flutter: ^0.2.4 - file_picker: ^10.3.6 + file_picker: ^11.0.2 file_saver: ^0.3.1 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter fluttericon: ^2.0.0 - font_awesome_flutter: ^10.12.0 + font_awesome_flutter: ^11.0.0 intl: any json_schema: ^5.2.2 package_info_plus: ^9.0.0 @@ -33,7 +33,7 @@ dev_dependencies: sdk: flutter build_runner: ^2.7.0 dart_pubspec_licenses: ^3.0.14 - drift_dev: ^2.29.0 + drift_dev: ^2.27.0 flutter_lints: ^6.0.0 flutter: diff --git a/test/db_tests/aggregates/group_test.dart b/test/db_tests/aggregates/group_test.dart index 5e713c4..3d51a06 100644 --- a/test/db_tests/aggregates/group_test.dart +++ b/test/db_tests/aggregates/group_test.dart @@ -29,10 +29,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testGroup1 = Group( name: 'Test Group', description: '', diff --git a/test/db_tests/aggregates/match_test.dart b/test/db_tests/aggregates/match_test.dart index dee0eb9..3305b9a 100644 --- a/test/db_tests/aggregates/match_test.dart +++ b/test/db_tests/aggregates/match_test.dart @@ -8,6 +8,7 @@ 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/data/models/score_entry.dart'; void main() { late AppDatabase database; @@ -36,11 +37,11 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); - testPlayer5 = Player(name: 'Eve', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testPlayer5 = Player(name: 'Eve'); testGroup1 = Group( name: 'Test Group 1', description: '', @@ -63,29 +64,24 @@ void main() { game: testGame, group: testGroup1, players: [testPlayer4, testPlayer5], - winner: testPlayer4, - notes: '', + scores: {testPlayer4.id: ScoreEntry(score: 1)}, ); testMatch2 = Match( name: 'Second Test Match', game: testGame, group: testGroup2, players: [testPlayer1, testPlayer2, testPlayer3], - winner: testPlayer2, - notes: '', ); testMatchOnlyPlayers = Match( name: 'Test Match with Players', game: testGame, players: [testPlayer1, testPlayer2, testPlayer3], - winner: testPlayer3, - notes: '', ); testMatchOnlyGroup = Match( name: 'Test Match with Group', game: testGame, group: testGroup2, - notes: '', + players: testGroup2.members, ); }); await database.playerDao.addPlayersAsList( @@ -289,8 +285,8 @@ void main() { matchId: testMatch1.id, ); - expect(fetchedMatch.winner, isNotNull); - expect(fetchedMatch.winner!.id, testPlayer4.id); + expect(fetchedMatch.mvp, isNotNull); + expect(fetchedMatch.mvp.first.id, testPlayer4.id); }); test('Setting a winner works correctly', () async { @@ -304,8 +300,8 @@ void main() { final fetchedMatch = await database.matchDao.getMatchById( matchId: testMatch1.id, ); - expect(fetchedMatch.winner, isNotNull); - expect(fetchedMatch.winner!.id, testPlayer5.id); + expect(fetchedMatch.mvp, isNotNull); + expect(fetchedMatch.mvp.first.id, testPlayer5.id); }); test( diff --git a/test/db_tests/aggregates/team_test.dart b/test/db_tests/aggregates/team_test.dart index 327bc8f..39c5be5 100644 --- a/test/db_tests/aggregates/team_test.dart +++ b/test/db_tests/aggregates/team_test.dart @@ -33,10 +33,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); testTeam3 = Team(name: 'Team Gamma', members: [testPlayer1, testPlayer3]); @@ -343,8 +343,16 @@ void main() { // Verifies that teams with overlapping members are independent. test('Teams with overlapping members are independent', () async { // Create two matches since player_match has primary key {playerId, matchId} - final match1 = Match(name: 'Match 1', game: testGame1, notes: ''); - final match2 = Match(name: 'Match 2', game: testGame2, notes: ''); + final match1 = Match( + name: 'Match 1', + game: testGame1, + players: [testPlayer1, testPlayer2], + ); + final match2 = Match( + name: 'Match 2', + game: testGame2, + players: [testPlayer1, testPlayer2], + ); await database.matchDao.addMatch(match: match1); await database.matchDao.addMatch(match: match2); diff --git a/test/db_tests/entities/player_test.dart b/test/db_tests/entities/player_test.dart index 7462bbf..1aab348 100644 --- a/test/db_tests/entities/player_test.dart +++ b/test/db_tests/entities/player_test.dart @@ -24,10 +24,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Test Player', description: ''); - testPlayer2 = Player(name: 'Second Player', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Test Player'); + testPlayer2 = Player(name: 'Second Player'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); }); }); tearDown(() async { @@ -348,7 +348,7 @@ void main() { // Verifies that a player with empty string name is stored correctly. test('Player with empty string name is stored correctly', () async { - final emptyNamePlayer = Player(name: '', description: ''); + final emptyNamePlayer = Player(name: ''); await database.playerDao.addPlayer(player: emptyNamePlayer); @@ -361,7 +361,7 @@ void main() { // Verifies that a player with very long name is stored correctly. test('Player with very long name is stored correctly', () async { final longName = 'A' * 1000; - final longNamePlayer = Player(name: longName, description: ''); + final longNamePlayer = Player(name: longName); await database.playerDao.addPlayer(player: longNamePlayer); diff --git a/test/db_tests/relationships/player_group_test.dart b/test/db_tests/relationships/player_group_test.dart index 7004e17..f687b1c 100644 --- a/test/db_tests/relationships/player_group_test.dart +++ b/test/db_tests/relationships/player_group_test.dart @@ -26,10 +26,10 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); testGroup = Group( name: 'Test Group', description: '', diff --git a/test/db_tests/relationships/player_match_test.dart b/test/db_tests/relationships/player_match_test.dart index 3db48de..92601f0 100644 --- a/test/db_tests/relationships/player_match_test.dart +++ b/test/db_tests/relationships/player_match_test.dart @@ -37,12 +37,12 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); - testPlayer4 = Player(name: 'Diana', description: ''); - testPlayer5 = Player(name: 'Eve', description: ''); - testPlayer6 = Player(name: 'Frank', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); + testPlayer4 = Player(name: 'Diana'); + testPlayer5 = Player(name: 'Eve'); + testPlayer6 = Player(name: 'Frank'); testGroup = Group( name: 'Test Group', description: '', @@ -58,14 +58,13 @@ void main() { testMatchOnlyGroup = Match( name: 'Test Match with Group', game: testGame, + players: testGroup.members, group: testGroup, - notes: '', ); testMatchOnlyPlayers = Match( name: 'Test Match with Players', game: testGame, players: [testPlayer4, testPlayer5, testPlayer6], - notes: '', ); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); @@ -96,7 +95,7 @@ void main() { matchId: testMatchOnlyGroup.id, ); - expect(matchHasPlayers, false); + expect(matchHasPlayers, true); await database.playerMatchDao.addPlayerToMatch( matchId: testMatchOnlyGroup.id, @@ -397,7 +396,6 @@ void main() { matchId: testMatchOnlyGroup.id, teamId: testTeam1.id, ); - expect(playersInTeam.length, 2); final playerIds = playersInTeam.map((p) => p.id).toSet(); expect(playerIds.contains(testPlayer1.id), true); @@ -426,18 +424,16 @@ void main() { playerId: testPlayer1.id, ); - // Try to add the same player again with different score await database.playerMatchDao.addPlayerToMatch( matchId: testMatchOnlyGroup.id, playerId: testPlayer1.id, ); - // Verify player count is still 1 final players = await database.playerMatchDao.getPlayersOfMatch( matchId: testMatchOnlyGroup.id, ); - expect(players?.length, 1); + expect(players?.length, 3); }); test( @@ -546,6 +542,7 @@ void main() { matchId: testMatchOnlyGroup.id, teamId: testTeam1.id, ); + expect(playersInTeam1.length, 2); final team1Ids = playersInTeam1.map((p) => p.id).toSet(); expect(team1Ids.contains(testPlayer1.id), true); @@ -568,13 +565,11 @@ void main() { name: 'Match 1', game: testGame, players: playersList, - notes: '', ); final match2 = Match( name: 'Match 2', game: testGame, players: playersList, - notes: '', ); await Future.wait([ diff --git a/test/db_tests/values/score_test.dart b/test/db_tests/values/score_test.dart index fc87cc4..d550995 100644 --- a/test/db_tests/values/score_test.dart +++ b/test/db_tests/values/score_test.dart @@ -30,9 +30,9 @@ void main() { ); withClock(fakeClock, () { - testPlayer1 = Player(name: 'Alice', description: ''); - testPlayer2 = Player(name: 'Bob', description: ''); - testPlayer3 = Player(name: 'Charlie', description: ''); + testPlayer1 = Player(name: 'Alice'); + testPlayer2 = Player(name: 'Bob'); + testPlayer3 = Player(name: 'Charlie'); testGame = Game( name: 'Test Game', ruleset: Ruleset.singleWinner, @@ -44,13 +44,11 @@ void main() { name: 'Test Match 1', game: testGame, players: [testPlayer1, testPlayer2], - notes: '', ); testMatch2 = Match( name: 'Test Match 2', game: testGame, players: [testPlayer2, testPlayer3], - notes: '', ); }); @@ -231,8 +229,8 @@ void main() { ); expect(scores.length, 2); - expect(scores[testPlayer1.id]!.length, 2); - expect(scores[testPlayer2.id]!.length, 1); + expect(scores[testPlayer1.id]!, isNotNull); + expect(scores[testPlayer2.id]!, isNotNull); }); test('getAllMatchScores() with no scores saved', () async { diff --git a/test/services/data_transfer_service_test.dart b/test/services/data_transfer_service_test.dart index 575e52f..e863629 100644 --- a/test/services/data_transfer_service_test.dart +++ b/test/services/data_transfer_service_test.dart @@ -64,14 +64,8 @@ void main() { players: [testPlayer1, testPlayer2], notes: 'Test notes', scores: { - testPlayer1.id: [ - ScoreEntry(roundNumber: 1, score: 10, change: 10), - ScoreEntry(roundNumber: 2, score: 20, change: 10), - ], - testPlayer2.id: [ - ScoreEntry(roundNumber: 1, score: 15, change: 15), - ScoreEntry(roundNumber: 2, score: 25, change: 10), - ], + testPlayer1.id: ScoreEntry(roundNumber: 1, score: 10, change: 10), + testPlayer2.id: ScoreEntry(roundNumber: 1, score: 15, change: 15), }, ); }); @@ -302,46 +296,25 @@ void main() { final scoresJson = matchData['scores'] as Map; expect(scoresJson, isA>()); - final scores = scoresJson.map( - (playerId, scoreList) => MapEntry( - playerId, - (scoreList as List) - .map((s) => ScoreEntry.fromJson(s as Map)) - .toList(), - ), - ); + // Verify scores are properly structured (single score per player, not list) + expect(scoresJson[testPlayer1.id], isNotNull); + expect(scoresJson[testPlayer2.id], isNotNull); - expect(scores, isA>>()); + // Parse player 1 score + final player1ScoreJson = + scoresJson[testPlayer1.id] as Map; + final player1Score = ScoreEntry.fromJson(player1ScoreJson); + expect(player1Score.roundNumber, 1); + expect(player1Score.score, 10); + expect(player1Score.change, 10); - /* Player 1 scores */ - // General structure - expect(scores[testPlayer1.id], isNotNull); - expect(scores[testPlayer1.id]!.length, 2); - - // Round 1 - expect(scores[testPlayer1.id]![0].roundNumber, 1); - expect(scores[testPlayer1.id]![0].score, 10); - expect(scores[testPlayer1.id]![0].change, 10); - - // Round 2 - expect(scores[testPlayer1.id]![1].roundNumber, 2); - expect(scores[testPlayer1.id]![1].score, 20); - expect(scores[testPlayer1.id]![1].change, 10); - - /* Player 2 scores */ - // General structure - expect(scores[testPlayer2.id], isNotNull); - expect(scores[testPlayer2.id]!.length, 2); - - // Round 1 - expect(scores[testPlayer2.id]![0].roundNumber, 1); - expect(scores[testPlayer2.id]![0].score, 15); - expect(scores[testPlayer2.id]![0].change, 15); - - // Round 2 - expect(scores[testPlayer2.id]![1].roundNumber, 2); - expect(scores[testPlayer2.id]![1].score, 25); - expect(scores[testPlayer2.id]![1].change, 10); + // Parse player 2 score + final player2ScoreJson = + scoresJson[testPlayer2.id] as Map; + final player2Score = ScoreEntry.fromJson(player2ScoreJson); + expect(player2Score.roundNumber, 1); + expect(player2Score.score, 15); + expect(player2Score.change, 15); }); testWidgets('Match without group is handled correctly', (tester) async { @@ -904,14 +877,8 @@ void main() { 'playerIds': [testPlayer1.id, testPlayer2.id], 'notes': testMatch.notes, 'scores': { - testPlayer1.id: [ - {'roundNumber': 1, 'score': 10, 'change': 10}, - {'roundNumber': 2, 'score': 20, 'change': 10}, - ], - testPlayer2.id: [ - {'roundNumber': 1, 'score': 15, 'change': 15}, - {'roundNumber': 2, 'score': 25, 'change': 10}, - ], + testPlayer1.id: {'roundNumber': 1, 'score': 10, 'change': 10}, + testPlayer2.id: {'roundNumber': 1, 'score': 15, 'change': 15}, }, 'createdAt': testMatch.createdAt.toIso8601String(), 'endedAt': null,