Merge pull request 'Verschiedene Regelsätze implementieren' (#194) from feature/132-verschiedene-regelsaetze-implementieren into development
All checks were successful
Push Pipeline / test (push) Successful in 44s
Push Pipeline / update_version (push) Successful in 5s
Push Pipeline / generate_licenses (push) Successful in 37s
Push Pipeline / format (push) Successful in 1m0s
Push Pipeline / build (push) Successful in 5m42s

Reviewed-on: #194
Reviewed-by: gelbeinhalb <spam@yannick-weigert.de>
This commit was merged in pull request #194.
This commit is contained in:
2026-04-24 10:31:36 +00:00
38 changed files with 1000 additions and 489 deletions

View File

@@ -1,5 +1,9 @@
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart
linter: linter:
rules: rules:
avoid_print: false avoid_print: false
@@ -11,8 +15,4 @@ linter:
prefer_const_literals_to_create_immutables: true prefer_const_literals_to_create_immutables: true
unnecessary_const: true unnecessary_const: true
lines_longer_than_80_chars: false lines_longer_than_80_chars: false
constant_identifier_names: false constant_identifier_names: false
analyzer:
exclude:
- lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart

View File

@@ -147,13 +147,19 @@
"type": "string" "type": "string"
}, },
"endedAt": { "endedAt": {
"type": ["string", "null"] "type": [
"string",
"null"
]
}, },
"gameId": { "gameId": {
"type": "string" "type": "string"
}, },
"groupId": { "groupId": {
"type": ["string", "null"] "type": [
"string",
"null"
]
}, },
"playerIds": { "playerIds": {
"type": "array", "type": "array",
@@ -163,22 +169,28 @@
}, },
"scores": { "scores": {
"type": "object", "type": "object",
"items": { "additionalProperties": {
"type": "array", "oneOf": [
"items": { {
"type": "string", "type": "null"
"properties": { },
"roundNumber": { {
"type": "number" "type": "object",
"properties": {
"roundNumber": {
"type": "number"
},
"score": {
"type": "number"
},
"change": {
"type": "number"
}
}, },
"score": { "required": ["roundNumber", "score", "change"],
"type": "number" "additionalProperties": false
},
"change": {
"type": "number"
}
} }
} ]
} }
}, },
"notes": { "notes": {

View File

@@ -51,3 +51,11 @@ String getNameCountText(Player player) {
} }
return ''; return '';
} }
String getPointLabel(AppLocalizations loc, int points) {
if (points == 1) {
return '$points ${loc.point}';
} else {
return '$points ${loc.points}';
}
}

View File

@@ -85,21 +85,21 @@ class CustomTheme {
); );
static const SearchBarThemeData searchBarTheme = SearchBarThemeData( static const SearchBarThemeData searchBarTheme = SearchBarThemeData(
textStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.textColor)), textStyle: WidgetStatePropertyAll(TextStyle(color: textColor)),
hintStyle: WidgetStatePropertyAll(TextStyle(color: CustomTheme.hintColor)), hintStyle: WidgetStatePropertyAll(TextStyle(color: hintColor)),
); );
static final RadioThemeData radioTheme = RadioThemeData( static final RadioThemeData radioTheme = RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return CustomTheme.primaryColor; return primaryColor;
} }
return CustomTheme.textColor; return textColor;
}), }),
); );
static const InputDecorationTheme inputDecorationTheme = InputDecorationTheme( static const InputDecorationTheme inputDecorationTheme = InputDecorationTheme(
labelStyle: TextStyle(color: CustomTheme.textColor), labelStyle: TextStyle(color: textColor),
hintStyle: TextStyle(color: CustomTheme.hintColor), hintStyle: TextStyle(color: hintColor),
); );
} }

View File

@@ -34,7 +34,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
matchId: row.id, matchId: row.id,
); );
final winner = await db.scoreEntryDao.getWinner(matchId: row.id);
return Match( return Match(
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -45,7 +44,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
createdAt: row.createdAt, createdAt: row.createdAt,
endedAt: row.endedAt, endedAt: row.endedAt,
scores: scores, scores: scores,
winner: winner,
); );
}), }),
); );
@@ -68,8 +66,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
final scores = await db.scoreEntryDao.getAllMatchScores(matchId: matchId); final scores = await db.scoreEntryDao.getAllMatchScores(matchId: matchId);
final winner = await db.scoreEntryDao.getWinner(matchId: matchId);
return Match( return Match(
id: result.id, id: result.id,
name: result.name, name: result.name,
@@ -80,7 +76,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
createdAt: result.createdAt, createdAt: result.createdAt,
endedAt: result.endedAt, endedAt: result.endedAt,
scores: scores, scores: scores,
winner: winner,
); );
} }
@@ -110,19 +105,14 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
} }
for (final pid in match.scores.keys) { for (final pid in match.scores.keys) {
final playerScores = match.scores[pid]!; final playerScores = match.scores[pid];
await db.scoreEntryDao.addScoresAsList( if (playerScores != null) {
entrys: playerScores, await db.scoreEntryDao.addScore(
playerId: pid, entry: playerScores,
matchId: match.id, playerId: pid,
); matchId: match.id,
} );
}
if (match.winner != null) {
await db.scoreEntryDao.setWinner(
matchId: match.id,
playerId: match.winner!.id,
);
} }
}); });
} }
@@ -140,6 +130,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
uniqueGames[match.game.id] = match.game; uniqueGames[match.game.id] = match.game;
} }
// Add games
if (uniqueGames.isNotEmpty) { if (uniqueGames.isNotEmpty) {
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
@@ -162,7 +153,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
); );
} }
// Add all groups of the matches in batch // Add groups
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
db.groupTable, db.groupTable,
@@ -181,7 +172,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
), ),
); );
// Add all matches in batch // Add matches
await db.batch( await db.batch(
(b) => b.insertAll( (b) => b.insertAll(
matchTable, matchTable,
@@ -202,7 +193,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
), ),
); );
// Add all players of the matches in batch (unique) // Add players
final uniquePlayers = <String, Player>{}; final uniquePlayers = <String, Player>{};
for (final match in matches) { for (final match in matches) {
for (final p in match.players) { for (final p in match.players) {
@@ -235,7 +226,27 @@ class MatchDao extends DatabaseAccessor<AppDatabase> 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) { await db.batch((b) {
for (final match in matches) { for (final match in matches) {
for (final p in match.players) { for (final p in match.players) {
@@ -251,7 +262,7 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
} }
}); });
// Add all player-group associations in batch // Add player-group associations
await db.batch((b) { await db.batch((b) {
for (final match in matches) { for (final match in matches) {
if (match.group != null) { if (match.group != null) {
@@ -300,7 +311,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
final group = await db.groupDao.getGroupById(groupId: groupId); final group = await db.groupDao.getGroupById(groupId: groupId);
final players = final players =
await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? []; await db.playerMatchDao.getPlayersOfMatch(matchId: row.id) ?? [];
final winner = await db.scoreEntryDao.getWinner(matchId: row.id);
return Match( return Match(
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -310,7 +320,6 @@ class MatchDao extends DatabaseAccessor<AppDatabase> with _$MatchDaoMixin {
notes: row.notes ?? '', notes: row.notes ?? '',
createdAt: row.createdAt, createdAt: row.createdAt,
endedAt: row.endedAt, endedAt: row.endedAt,
winner: winner,
); );
}), }),
); );

View File

@@ -24,7 +24,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
matchId: matchId, matchId: matchId,
teamId: Value(teamId), teamId: Value(teamId),
), ),
mode: InsertMode.insertOrIgnore, mode: InsertMode.insertOrReplace,
); );
} }

View File

@@ -83,21 +83,21 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
} }
/// Retrieves all scores for a specific match. /// Retrieves all scores for a specific match.
Future<Map<String, List<ScoreEntry>>> getAllMatchScores({ Future<Map<String, ScoreEntry?>> getAllMatchScores({
required String matchId, required String matchId,
}) async { }) async {
final query = select(scoreEntryTable) final query = select(scoreEntryTable)
..where((s) => s.matchId.equals(matchId)); ..where((s) => s.matchId.equals(matchId));
final result = await query.get(); final result = await query.get();
final Map<String, List<ScoreEntry>> scoresByPlayer = {}; final Map<String, ScoreEntry?> scoresByPlayer = {};
for (final row in result) { for (final row in result) {
final score = ScoreEntry( final score = ScoreEntry(
roundNumber: row.roundNumber, roundNumber: row.roundNumber,
score: row.score, score: row.score,
change: row.change, change: row.change,
); );
scoresByPlayer.putIfAbsent(row.playerId, () => []).add(score); scoresByPlayer[row.playerId] = score;
} }
return scoresByPlayer; return scoresByPlayer;
@@ -235,22 +235,29 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
return rowsAffected > 0; 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<Player?> getWinner({required String matchId}) async { Future<Player?> getWinner({required String matchId}) async {
final query = select(scoreEntryTable) final query =
..where((s) => s.matchId.equals(matchId)) select(scoreEntryTable).join([
..orderBy([(s) => OrderingTerm.desc(s.score)]) innerJoin(
..limit(1); db.playerTable,
final result = await query.getSingleOrNull(); 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( return Player(
id: player.id, id: playerData.id,
name: player.name, name: playerData.name,
createdAt: player.createdAt, createdAt: playerData.createdAt,
description: player.description, description: playerData.description,
); );
} }
@@ -295,20 +302,29 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
return rowsAffected > 0; 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<Player?> getLooser({required String matchId}) async { Future<Player?> getLooser({required String matchId}) async {
final query = select(scoreEntryTable) final query =
..where((s) => s.matchId.equals(matchId) & s.score.equals(0)); select(scoreEntryTable).join([
final result = await query.getSingleOrNull(); 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( return Player(
id: player.id, id: playerData.id,
name: player.name, name: playerData.name,
createdAt: player.createdAt, createdAt: playerData.createdAt,
description: player.description, description: playerData.description,
); );
} }

View File

@@ -1,6 +1,6 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:uuid/uuid.dart';
import 'package:tallee/core/enums.dart'; import 'package:tallee/core/enums.dart';
import 'package:uuid/uuid.dart';
class Game { class Game {
final String id; final String id;
@@ -33,7 +33,10 @@ class Game {
: id = json['id'], : id = json['id'],
createdAt = DateTime.parse(json['createdAt']), createdAt = DateTime.parse(json['createdAt']),
name = json['name'], 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'], description = json['description'],
color = GameColor.values.firstWhere((e) => e.name == json['color']), color = GameColor.values.firstWhere((e) => e.name == json['color']),
icon = json['icon']; icon = json['icon'];
@@ -49,4 +52,3 @@ class Game {
'icon': icon, 'icon': icon,
}; };
} }

View File

@@ -15,27 +15,25 @@ class Match {
final Group? group; final Group? group;
final List<Player> players; final List<Player> players;
final String notes; final String notes;
Map<String, List<ScoreEntry>> scores; Map<String, ScoreEntry?> scores;
Player? winner;
Match({ Match({
String? id,
DateTime? createdAt,
this.endedAt,
required this.name, required this.name,
required this.game, required this.game,
required this.players,
this.endedAt,
this.group, this.group,
this.players = const [],
this.notes = '', this.notes = '',
Map<String, List<ScoreEntry>>? scores, String? id,
this.winner, DateTime? createdAt,
Map<String, ScoreEntry?>? scores,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? clock.now(), createdAt = createdAt ?? clock.now(),
scores = scores ?? {for (var player in players) player.id: []}; scores = scores ?? {for (Player p in players) p.id: null};
@override @override
String toString() { 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 /// Creates a Match instance from a JSON object where related objects are
@@ -57,7 +55,16 @@ class Match {
), ),
group = null, group = null,
players = [], players = [],
scores = json['scores'], scores = json['scores'] != null
? (json['scores'] as Map<String, dynamic>).map(
(key, value) => MapEntry(
key,
value != null
? ScoreEntry.fromJson(value as Map<String, dynamic>)
: null,
),
)
: {},
notes = json['notes'] ?? ''; notes = json['notes'] ?? '';
/// Converts the Match instance to a JSON object. Related objects are /// Converts the Match instance to a JSON object. Related objects are
@@ -71,10 +78,62 @@ class Match {
'gameId': game.id, 'gameId': game.id,
'groupId': group?.id, 'groupId': group?.id,
'playerIds': players.map((player) => player.id).toList(), 'playerIds': players.map((player) => player.id).toList(),
'scores': scores.map( 'scores': scores.map((key, value) => MapEntry(key, value?.toJson())),
(playerId, scoreList) =>
MapEntry(playerId, scoreList.map((score) => score.toJson()).toList()),
),
'notes': notes, 'notes': notes,
}; };
List<Player> 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<Player> _getPlayersWithHighestScore() {
if (players.isEmpty || scores.values.every((score) => score == null)) {
return [];
}
final int highestScore = players
.map((player) => scores[player.id]?.score)
.whereType<int>()
.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<Player> _getPlayersWithLowestScore() {
if (players.isEmpty || scores.values.every((score) => score == null)) {
return [];
}
final int lowestScore = players
.map((player) => scores[player.id]?.score)
.whereType<int>()
.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();
}
} }

View File

@@ -1,13 +1,14 @@
class ScoreEntry { class ScoreEntry {
int roundNumber = 0; final int roundNumber;
final int score; final int score;
final int change; final int change;
ScoreEntry({ ScoreEntry({required this.score, this.roundNumber = 0, this.change = 0});
required this.roundNumber,
required this.score, @override
required this.change, String toString() {
}); return 'ScoreEntry{roundNumber: $roundNumber, score: $score, change: $change}';
}
ScoreEntry.fromJson(Map<String, dynamic> json) ScoreEntry.fromJson(Map<String, dynamic> json)
: roundNumber = json['roundNumber'], : roundNumber = json['roundNumber'],

View File

@@ -22,10 +22,11 @@
"days_ago": "vor {count} Tagen", "days_ago": "vor {count} Tagen",
"delete": "Löschen", "delete": "Löschen",
"delete_all_data": "Alle Daten 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", "delete_match": "Spiel löschen",
"edit_group": "Gruppe bearbeiten", "edit_group": "Gruppe bearbeiten",
"edit_match": "Gruppe bearbeiten", "edit_match": "Gruppe bearbeiten",
"enter_points": "Punkte eingeben",
"enter_results": "Ergebnisse eintragen", "enter_results": "Ergebnisse eintragen",
"error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen", "error_creating_group": "Fehler beim Erstellen der Gruppe, bitte erneut versuchen",
"error_deleting_group": "Fehler beim Löschen 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", "player_name": "Spieler:innenname",
"players": "Spieler:innen", "players": "Spieler:innen",
"players_count": "{count} Spieler", "players_count": "{count} Spieler",
"point": "Punkt",
"points": "Punkte",
"privacy_policy": "Datenschutzerklärung", "privacy_policy": "Datenschutzerklärung",
"quick_create": "Schnellzugriff", "quick_create": "Schnellzugriff",
"recent_matches": "Letzte Spiele", "recent_matches": "Letzte Spiele",
@@ -87,12 +90,14 @@
"save_changes": "Änderungen speichern", "save_changes": "Änderungen speichern",
"search_for_groups": "Nach Gruppen suchen", "search_for_groups": "Nach Gruppen suchen",
"search_for_players": "Nach Spieler:innen 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", "selected_players": "Ausgewählte Spieler:innen",
"settings": "Einstellungen", "settings": "Einstellungen",
"single_loser": "Ein:e Verlierer:in", "single_loser": "Ein:e Verlierer:in",
"single_winner": "Ein:e Gewinner:in", "single_winner": "Ein:e Gewinner:in",
"highest_score": "Höchste Punkte", "highest_score": "Höchste Punkte",
"loser": "Verlierer:in",
"lowest_score": "Niedrigste Punkte", "lowest_score": "Niedrigste Punkte",
"multiple_winners": "Mehrere Gewinner:innen", "multiple_winners": "Mehrere Gewinner:innen",
"statistics": "Statistiken", "statistics": "Statistiken",
@@ -100,6 +105,7 @@
"successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt", "successfully_added_player": "Spieler:in {playerName} erfolgreich hinzugefügt",
"there_is_no_group_matching_your_search": "Es gibt keine Gruppe, die deiner Suche entspricht", "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.", "this_cannot_be_undone": "Dies kann nicht rückgängig gemacht werden.",
"tie": "Unentschieden",
"today_at": "Heute um", "today_at": "Heute um",
"undo": "Rückgängig", "undo": "Rückgängig",
"unknown_exception": "Unbekannter Fehler (siehe Konsole)", "unknown_exception": "Unbekannter Fehler (siehe Konsole)",

View File

@@ -83,6 +83,9 @@
"@edit_match": { "@edit_match": {
"description": "Button & Appbar label for editing a match" "description": "Button & Appbar label for editing a match"
}, },
"@enter_points": {
"description": "Label to enter players points"
},
"@enter_results": { "@enter_results": {
"description": "Button text to enter match results" "description": "Button text to enter match results"
}, },
@@ -232,6 +235,9 @@
} }
} }
}, },
"@points": {
"description": "Points label"
},
"@privacy_policy": { "@privacy_policy": {
"description": "Privacy policy menu item" "description": "Privacy policy menu item"
}, },
@@ -271,6 +277,9 @@
"@select_winner": { "@select_winner": {
"description": "Label to select the winner" "description": "Label to select the winner"
}, },
"@select_loser": {
"description": "Label to select the loser"
},
"@selected_players": { "@selected_players": {
"description": "Shows the number of selected players" "description": "Shows the number of selected players"
}, },
@@ -351,6 +360,7 @@
"delete_match": "Delete Match", "delete_match": "Delete Match",
"edit_group": "Edit Group", "edit_group": "Edit Group",
"edit_match": "Edit Match", "edit_match": "Edit Match",
"enter_points": "Enter points",
"enter_results": "Enter Results", "enter_results": "Enter Results",
"error_creating_group": "Error while creating group, please try again", "error_creating_group": "Error while creating group, please try again",
"error_deleting_group": "Error while deleting group, please try again", "error_deleting_group": "Error while deleting group, please try again",
@@ -399,6 +409,8 @@
"player_name": "Player name", "player_name": "Player name",
"players": "Players", "players": "Players",
"players_count": "{count} Players", "players_count": "{count} Players",
"point": "Point",
"points": "Points",
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
"quick_create": "Quick Create", "quick_create": "Quick Create",
"recent_matches": "Recent Matches", "recent_matches": "Recent Matches",
@@ -411,12 +423,14 @@
"save_changes": "Save Changes", "save_changes": "Save Changes",
"search_for_groups": "Search for groups", "search_for_groups": "Search for groups",
"search_for_players": "Search for players", "search_for_players": "Search for players",
"select_winner": "Select Winner:", "select_winner": "Select Winner",
"select_loser": "Select Loser",
"selected_players": "Selected players", "selected_players": "Selected players",
"settings": "Settings", "settings": "Settings",
"single_loser": "Single Loser", "single_loser": "Single Loser",
"single_winner": "Single Winner", "single_winner": "Single Winner",
"highest_score": "Highest Score", "highest_score": "Highest Score",
"loser": "Loser",
"lowest_score": "Lowest Score", "lowest_score": "Lowest Score",
"multiple_winners": "Multiple Winners", "multiple_winners": "Multiple Winners",
"statistics": "Statistics", "statistics": "Statistics",
@@ -424,6 +438,7 @@
"successfully_added_player": "Successfully added player {playerName}", "successfully_added_player": "Successfully added player {playerName}",
"there_is_no_group_matching_your_search": "There is no group matching your search", "there_is_no_group_matching_your_search": "There is no group matching your search",
"this_cannot_be_undone": "This can't be undone.", "this_cannot_be_undone": "This can't be undone.",
"tie": "Tie",
"today_at": "Today at", "today_at": "Today at",
"undo": "Undo", "undo": "Undo",
"unknown_exception": "Unknown Exception (see console)", "unknown_exception": "Unknown Exception (see console)",

View File

@@ -254,6 +254,12 @@ abstract class AppLocalizations {
/// **'Edit Match'** /// **'Edit Match'**
String get 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 /// Button text to enter match results
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -542,6 +548,18 @@ abstract class AppLocalizations {
/// **'{count} Players'** /// **'{count} Players'**
String players_count(int count); 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 /// Privacy policy menu item
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -617,9 +635,15 @@ abstract class AppLocalizations {
/// Label to select the winner /// Label to select the winner
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Select Winner:'** /// **'Select Winner'**
String get 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 /// Shows the number of selected players
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -650,6 +674,12 @@ abstract class AppLocalizations {
/// **'Highest Score'** /// **'Highest Score'**
String get 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. /// No description provided for @lowest_score.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -692,6 +722,12 @@ abstract class AppLocalizations {
/// **'This can\'t be undone.'** /// **'This can\'t be undone.'**
String get this_cannot_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 /// Date format for today
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -79,7 +79,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get delete_all_data => 'Alle Daten löschen'; String get delete_all_data => 'Alle Daten löschen';
@override @override
String get delete_group => 'Diese Gruppe löschen'; String get delete_group => 'Gruppe löschen';
@override @override
String get delete_match => 'Spiel löschen'; String get delete_match => 'Spiel löschen';
@@ -90,6 +90,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get edit_match => 'Gruppe bearbeiten'; String get edit_match => 'Gruppe bearbeiten';
@override
String get enter_points => 'Punkte eingeben';
@override @override
String get enter_results => 'Ergebnisse eintragen'; String get enter_results => 'Ergebnisse eintragen';
@@ -240,6 +243,12 @@ class AppLocalizationsDe extends AppLocalizations {
return '$count Spieler'; return '$count Spieler';
} }
@override
String get point => 'Punkt';
@override
String get points => 'Punkte';
@override @override
String get privacy_policy => 'Datenschutzerklärung'; String get privacy_policy => 'Datenschutzerklärung';
@@ -281,7 +290,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get search_for_players => 'Nach Spieler:innen suchen'; String get search_for_players => 'Nach Spieler:innen suchen';
@override @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 @override
String get selected_players => 'Ausgewählte Spieler:innen'; String get selected_players => 'Ausgewählte Spieler:innen';
@@ -298,6 +310,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get highest_score => 'Höchste Punkte'; String get highest_score => 'Höchste Punkte';
@override
String get loser => 'Verlierer:in';
@override @override
String get lowest_score => 'Niedrigste Punkte'; String get lowest_score => 'Niedrigste Punkte';
@@ -323,6 +338,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get this_cannot_be_undone => String get this_cannot_be_undone =>
'Dies kann nicht rückgängig gemacht werden.'; 'Dies kann nicht rückgängig gemacht werden.';
@override
String get tie => 'Unentschieden';
@override @override
String get today_at => 'Heute um'; String get today_at => 'Heute um';

View File

@@ -90,6 +90,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get edit_match => 'Edit Match'; String get edit_match => 'Edit Match';
@override
String get enter_points => 'Enter points';
@override @override
String get enter_results => 'Enter Results'; String get enter_results => 'Enter Results';
@@ -240,6 +243,12 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count Players'; return '$count Players';
} }
@override
String get point => 'Point';
@override
String get points => 'Points';
@override @override
String get privacy_policy => 'Privacy Policy'; String get privacy_policy => 'Privacy Policy';
@@ -281,7 +290,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get search_for_players => 'Search for players'; String get search_for_players => 'Search for players';
@override @override
String get select_winner => 'Select Winner:'; String get select_winner => 'Select Winner';
@override
String get select_loser => 'Select Loser';
@override @override
String get selected_players => 'Selected players'; String get selected_players => 'Selected players';
@@ -298,6 +310,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get highest_score => 'Highest Score'; String get highest_score => 'Highest Score';
@override
String get loser => 'Loser';
@override @override
String get lowest_score => 'Lowest Score'; String get lowest_score => 'Lowest Score';
@@ -322,6 +337,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get this_cannot_be_undone => 'This can\'t be undone.'; String get this_cannot_be_undone => 'This can\'t be undone.';
@override
String get tie => 'Tie';
@override @override
String get today_at => 'Today at'; String get today_at => 'Today at';

View File

@@ -148,7 +148,7 @@ class _CreateGroupViewState extends State<CreateGroupView> {
final groupName = _groupNameController.text.trim(); final groupName = _groupNameController.text.trim();
final success = await db.groupDao.addGroup( final success = await db.groupDao.addGroup(
group: Group(name: groupName, description: '', members: selectedPlayers), group: Group(name: groupName, members: selectedPlayers),
); );
return success; return success;

View File

@@ -255,28 +255,37 @@ class _GroupDetailViewState extends State<GroupDetailView> {
/// Determines the best player in the group based on match wins /// Determines the best player in the group based on match wins
String _getBestPlayer(List<Match> matches) { String _getBestPlayer(List<Match> matches) {
final bestPlayerCounts = <Player, int>{}; final mvpCounts = <Player, int>{};
// Count wins for each player
for (var match in matches) { for (var match in matches) {
if (match.winner != null && final mvps = match.mvp;
_group.members.any((m) => m.id == match.winner?.id)) { for (final mvpPlayer in mvps) {
print(match.winner); if (_group.members.any((m) => m.id == mvpPlayer.id)) {
bestPlayerCounts.update( mvpCounts.update(mvpPlayer, (value) => value + 1, ifAbsent: () => 1);
match.winner!, }
(value) => value + 1,
ifAbsent: () => 1,
);
} }
} }
// Sort players by win count final sortedMvps = mvpCounts.entries.toList()
final sortedPlayers = bestPlayerCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value)); ..sort((a, b) => b.value.compareTo(a.value));
// Get the best player if (sortedMvps.isEmpty) {
bestPlayer = sortedPlayers.isNotEmpty ? sortedPlayers.first.key.name : '-'; 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;
}
} }
} }

View File

@@ -36,7 +36,7 @@ class _GroupViewState extends State<GroupView> {
Group( Group(
name: 'Skeleton Group', name: 'Skeleton Group',
description: '', description: '',
members: List.filled(6, Player(name: 'Skeleton Player', description: '')), members: List.filled(6, Player(name: 'Skeleton Player')),
), ),
); );

View File

@@ -43,21 +43,41 @@ class _HomeViewState extends State<HomeView> {
Match( Match(
name: 'Skeleton Match', name: 'Skeleton Match',
game: Game( game: Game(
name: '', name: 'Skeleton Game',
ruleset: Ruleset.singleWinner, ruleset: Ruleset.singleWinner,
description: '', description: 'This is a skeleton game description.',
color: GameColor.blue, color: GameColor.blue,
icon: '', icon: '',
), ),
group: Group( group: Group(
name: 'Skeleton Group', name: 'Skeleton Group',
description: '', description: 'This is a skeleton group description.',
members: [ members: [
Player(name: 'Skeleton Player 1', description: ''), Player(
Player(name: 'Skeleton Player 2', description: ''), 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<HomeView> {
MatchResultView(match: match), MatchResultView(match: match),
), ),
); );
await updatedWinnerInRecentMatches(match.id); await loadRecentMatches();
setState(() {
print('loaded');
});
}, },
), ),
) )
@@ -224,15 +248,12 @@ class _HomeViewState extends State<HomeView> {
}); });
} }
/// Updates the winner information for a specific match in the recent matches list. Future<void> loadRecentMatches() async {
Future<void> updatedWinnerInRecentMatches(String matchId) async {
final db = Provider.of<AppDatabase>(context, listen: false); final db = Provider.of<AppDatabase>(context, listen: false);
final winner = await db.scoreEntryDao.getWinner(matchId: matchId); final matches = await db.matchDao.getAllMatches();
final matchIndex = recentMatches.indexWhere((match) => match.id == matchId); recentMatches =
if (matchIndex != -1) { (matches..sort((a, b) => b.createdAt.compareTo(a.createdAt)))
setState(() { .take(2)
recentMatches[matchIndex].winner = winner; .toList();
});
}
} }
} }

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tallee/core/common.dart'; import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.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/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart'; import 'package:tallee/presentation/widgets/text_input/custom_search_bar.dart';
import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/title_description_list_tile.dart';
@@ -13,14 +13,14 @@ class ChooseGameView extends StatefulWidget {
const ChooseGameView({ const ChooseGameView({
super.key, super.key,
required this.games, required this.games,
required this.initialGameIndex, required this.initialGameId,
}); });
/// A list of tuples containing the game name, description and ruleset /// A list of tuples containing the game name, description and ruleset
final List<(String, String, Ruleset)> games; final List<Game> games;
/// The index of the initially selected game /// The id of the initially selected game
final int initialGameIndex; final String initialGameId;
@override @override
State<ChooseGameView> createState() => _ChooseGameViewState(); State<ChooseGameView> createState() => _ChooseGameViewState();
@@ -31,11 +31,11 @@ class _ChooseGameViewState extends State<ChooseGameView> {
final TextEditingController searchBarController = TextEditingController(); final TextEditingController searchBarController = TextEditingController();
/// Currently selected game index /// Currently selected game index
late int selectedGameIndex; late String selectedGameId;
@override @override
void initState() { void initState() {
selectedGameIndex = widget.initialGameIndex; selectedGameId = widget.initialGameId;
super.initState(); super.initState();
} }
@@ -49,7 +49,13 @@ class _ChooseGameViewState extends State<ChooseGameView> {
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios), icon: const Icon(Icons.arrow_back_ios),
onPressed: () { onPressed: () {
Navigator.of(context).pop(selectedGameIndex); Navigator.of(context).pop(
selectedGameId == ''
? null
: widget.games.firstWhere(
(game) => game.id == selectedGameId,
),
);
}, },
), ),
title: Text(loc.choose_game), title: Text(loc.choose_game),
@@ -62,7 +68,7 @@ class _ChooseGameViewState extends State<ChooseGameView> {
if (didPop) { if (didPop) {
return; return;
} }
Navigator.of(context).pop(selectedGameIndex); Navigator.of(context).pop(widget.initialGameId);
}, },
child: Column( child: Column(
children: [ children: [
@@ -79,19 +85,19 @@ class _ChooseGameViewState extends State<ChooseGameView> {
itemCount: widget.games.length, itemCount: widget.games.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
return TitleDescriptionListTile( return TitleDescriptionListTile(
title: widget.games[index].$1, title: widget.games[index].name,
description: widget.games[index].$2, description: widget.games[index].description,
badgeText: translateRulesetToString( badgeText: translateRulesetToString(
widget.games[index].$3, widget.games[index].ruleset,
context, context,
), ),
isHighlighted: selectedGameIndex == index, isHighlighted: selectedGameId == widget.games[index].id,
onPressed: () async { onPressed: () async {
setState(() { setState(() {
if (selectedGameIndex == index) { if (selectedGameId != widget.games[index].id) {
selectedGameIndex = -1; selectedGameId = widget.games[index].id;
} else { } else {
selectedGameIndex = index; selectedGameId = '';
} }
}); });
}, },

View File

@@ -20,7 +20,9 @@ import 'package:tallee/presentation/widgets/tiles/choose_tile.dart';
class CreateMatchView extends StatefulWidget { class CreateMatchView extends StatefulWidget {
/// A view that allows creating a new match /// 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({ const CreateMatchView({
super.key, super.key,
this.onWinnerChanged, this.onWinnerChanged,
@@ -28,13 +30,11 @@ class CreateMatchView extends StatefulWidget {
this.onMatchUpdated, this.onMatchUpdated,
}); });
/// Optional callback invoked when the winner is changed
final VoidCallback? onWinnerChanged; final VoidCallback? onWinnerChanged;
/// Optional callback invoked when the match is updated
final void Function(Match)? onMatchUpdated; final void Function(Match)? onMatchUpdated;
/// An optional match to prefill the fields /// An optional match to prefill the fields for editing.
final Match? matchToEdit; final Match? matchToEdit;
@override @override
@@ -50,20 +50,12 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// Hint text for the match name input field /// Hint text for the match name input field
String? hintText; String? hintText;
/// List of all groups from the database
List<Group> groupsList = []; List<Group> groupsList = [];
/// List of all players from the database
List<Player> playerList = []; List<Player> playerList = [];
List<Game> gamesList = [];
/// The currently selected group
Group? selectedGroup; Group? selectedGroup;
Game? selectedGame;
/// The index of the currently selected game in [games] to mark it in
/// the [ChooseGameView]
int selectedGameIndex = -1;
/// The currently selected players
List<Player> selectedPlayers = []; List<Player> selectedPlayers = [];
/// GlobalKey for ScaffoldMessenger to show snackbars /// GlobalKey for ScaffoldMessenger to show snackbars
@@ -81,12 +73,14 @@ class _CreateMatchViewState extends State<CreateMatchView> {
Future.wait([ Future.wait([
db.groupDao.getAllGroups(), db.groupDao.getAllGroups(),
db.playerDao.getAllPlayers(), db.playerDao.getAllPlayers(),
db.gameDao.getAllGames(),
]).then((result) async { ]).then((result) async {
groupsList = result[0] as List<Group>; groupsList = result[0] as List<Group>;
playerList = result[1] as List<Player>; playerList = result[1] as List<Player>;
gamesList = (result[2] as List<Game>);
// If a match is provided, prefill the fields // If a match is provided, prefill the fields
if (widget.matchToEdit != null) { if (isEditMode()) {
prefillMatchDetails(); prefillMatchDetails();
} }
}); });
@@ -105,20 +99,11 @@ class _CreateMatchViewState extends State<CreateMatchView> {
hintText ??= loc.match_name; hintText ??= loc.match_name;
} }
List<(String, String, Ruleset)> games = [
('Example Game 1', 'This is a description', Ruleset.lowestScore),
('Example Game 2', '', Ruleset.singleWinner),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
final buttonText = widget.matchToEdit != null final buttonText = isEditMode() ? loc.save_changes : loc.create_match;
? loc.save_changes final viewTitle = isEditMode() ? loc.edit_match : loc.create_new_match;
: loc.create_match;
final viewTitle = widget.matchToEdit != null
? loc.edit_match
: loc.create_new_match;
return ScaffoldMessenger( return ScaffoldMessenger(
key: _scaffoldMessengerKey, key: _scaffoldMessengerKey,
@@ -140,21 +125,21 @@ class _CreateMatchViewState extends State<CreateMatchView> {
), ),
ChooseTile( ChooseTile(
title: loc.game, title: loc.game,
trailingText: selectedGameIndex == -1 trailingText: selectedGame == null
? loc.none ? loc.none_group
: games[selectedGameIndex].$1, : selectedGame!.name,
onPressed: () async { onPressed: () async {
selectedGameIndex = await Navigator.of(context).push( selectedGame = await Navigator.of(context).push(
adaptivePageRoute( adaptivePageRoute(
builder: (context) => ChooseGameView( builder: (context) => ChooseGameView(
games: games, games: gamesList,
initialGameIndex: selectedGameIndex, initialGameId: selectedGame?.id ?? '',
), ),
), ),
); );
setState(() { setState(() {
if (selectedGameIndex != -1) { if (selectedGame != null) {
hintText = games[selectedGameIndex].$1; hintText = selectedGame!.name;
} else { } else {
hintText = loc.match_name; hintText = loc.match_name;
} }
@@ -225,6 +210,10 @@ class _CreateMatchViewState extends State<CreateMatchView> {
); );
} }
bool isEditMode() {
return widget.matchToEdit != null;
}
/// Determines whether the "Create Match" button should be enabled. /// Determines whether the "Create Match" button should be enabled.
/// ///
/// Returns `true` if: /// Returns `true` if:
@@ -232,7 +221,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// - Either a group is selected OR at least 2 players are selected /// - Either a group is selected OR at least 2 players are selected
bool _enableCreateGameButton() { bool _enableCreateGameButton() {
return (selectedGroup != null || 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 // If a match was provided to the view, it updates the match in the database
@@ -240,7 +229,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
// If no match was provided, it creates a new match in the database and // If no match was provided, it creates a new match in the database and
// navigates to the MatchResultView for the newly created match. // navigates to the MatchResultView for the newly created match.
void buttonNavigation(BuildContext context) async { void buttonNavigation(BuildContext context) async {
if (widget.matchToEdit != null) { if (isEditMode()) {
await updateMatch(); await updateMatch();
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
@@ -266,9 +255,6 @@ class _CreateMatchViewState extends State<CreateMatchView> {
/// Updates attributes of the existing match in the database based on the /// Updates attributes of the existing match in the database based on the
/// changes made in the edit view. /// changes made in the edit view.
Future<void> updateMatch() async { Future<void> updateMatch() async {
//TODO: Remove when Games implemented
final tempGame = await getTemporaryGame();
final updatedMatch = Match( final updatedMatch = Match(
id: widget.matchToEdit!.id, id: widget.matchToEdit!.id,
name: _matchNameController.text.isEmpty name: _matchNameController.text.isEmpty
@@ -276,8 +262,7 @@ class _CreateMatchViewState extends State<CreateMatchView> {
: _matchNameController.text.trim(), : _matchNameController.text.trim(),
group: selectedGroup, group: selectedGroup,
players: selectedPlayers, players: selectedPlayers,
game: tempGame, game: widget.matchToEdit!.game,
winner: widget.matchToEdit!.winner,
createdAt: widget.matchToEdit!.createdAt, createdAt: widget.matchToEdit!.createdAt,
endedAt: widget.matchToEdit!.endedAt, endedAt: widget.matchToEdit!.endedAt,
notes: widget.matchToEdit!.notes, notes: widget.matchToEdit!.notes,
@@ -314,9 +299,6 @@ class _CreateMatchViewState extends State<CreateMatchView> {
matchId: widget.matchToEdit!.id, matchId: widget.matchToEdit!.id,
playerId: player.id, playerId: player.id,
); );
if (widget.matchToEdit!.winner?.id == player.id) {
updatedMatch.winner = null;
}
} }
} }
@@ -326,8 +308,6 @@ class _CreateMatchViewState extends State<CreateMatchView> {
// Creates a new match and adds it to the database. // Creates a new match and adds it to the database.
// Returns the created match. // Returns the created match.
Future<Match> createMatch() async { Future<Match> createMatch() async {
final tempGame = await getTemporaryGame();
Match match = Match( Match match = Match(
name: _matchNameController.text.isEmpty name: _matchNameController.text.isEmpty
? (hintText ?? '') ? (hintText ?? '')
@@ -335,35 +315,18 @@ class _CreateMatchViewState extends State<CreateMatchView> {
createdAt: DateTime.now(), createdAt: DateTime.now(),
group: selectedGroup, group: selectedGroup,
players: selectedPlayers, players: selectedPlayers,
game: tempGame, game: selectedGame!,
); );
await db.matchDao.addMatch(match: match); await db.matchDao.addMatch(match: match);
return match; return match;
} }
// TODO: Remove when games fully implemented
Future<Game> 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 // If a match was provided to the view, this method prefills the input fields
void prefillMatchDetails() { void prefillMatchDetails() {
final match = widget.matchToEdit!; final match = widget.matchToEdit!;
_matchNameController.text = match.name; _matchNameController.text = match.name;
selectedPlayers = match.players; selectedPlayers = match.players;
selectedGameIndex = 0; selectedGame = match.game;
if (match.group != null) { if (match.group != null) {
selectedGroup = match.group; selectedGroup = match.group;

View File

@@ -170,37 +170,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
vertical: 4, vertical: 4,
horizontal: 8, horizontal: 8,
), ),
child: Row( child: getResultWidget(loc),
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,
),
),
],
],
),
), ),
), ),
], ],
@@ -227,7 +197,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
text: loc.enter_results, text: loc.enter_results,
icon: Icons.emoji_events, icon: Icons.emoji_events,
onPressed: () async { onPressed: () async {
match.winner = await Navigator.push( await Navigator.push(
context, context,
adaptivePageRoute( adaptivePageRoute(
fullscreenDialog: true, fullscreenDialog: true,
@@ -259,4 +229,108 @@ class _MatchDetailViewState extends State<MatchDetailView> {
}); });
widget.onMatchUpdate.call(); 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<Widget> 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;
}
} }

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tallee/core/custom_theme.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/db/database.dart';
import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.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/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/custom_radio_list_tile.dart';
import 'package:tallee/presentation/widgets/tiles/score_list_tile.dart';
class MatchResultView extends StatefulWidget { class MatchResultView extends StatefulWidget {
/// A view that allows selecting and saving the winner of a match /// A view that allows selecting and saving the winner of a match
@@ -26,30 +30,61 @@ class MatchResultView extends StatefulWidget {
class _MatchResultViewState extends State<MatchResultView> { class _MatchResultViewState extends State<MatchResultView> {
late final AppDatabase db; late final AppDatabase db;
late final Ruleset ruleset;
/// List of all players who participated in the match /// List of all players who participated in the match
late final List<Player> allPlayers; late final List<Player> allPlayers;
/// List of text controllers for score entry, one for each player
late final List<TextEditingController> controller;
late bool canSave;
/// Currently selected winner player /// Currently selected winner player
Player? _selectedPlayer; Player? _selectedPlayer;
@override @override
void initState() { void initState() {
db = Provider.of<AppDatabase>(context, listen: false); db = Provider.of<AppDatabase>(context, listen: false);
ruleset = widget.match.game.ruleset;
canSave = !rulesetSupportsScoreEntry();
allPlayers = widget.match.players; allPlayers = widget.match.players;
allPlayers.sort((a, b) => a.name.compareTo(b.name)); allPlayers.sort((a, b) => a.name.compareTo(b.name));
if (widget.match.winner != null) { controller = List.generate(
_selectedPlayer = allPlayers.firstWhere( allPlayers.length,
(p) => p.id == widget.match.winner!.id, (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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
return Scaffold( return Scaffold(
backgroundColor: CustomTheme.backgroundColor, backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar( appBar: AppBar(
@@ -85,67 +120,169 @@ class _MatchResultViewState extends State<MatchResultView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
loc.select_winner, '${getTitleForRuleset(loc)}:',
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Expanded( if (rulesetSupportsWinnerSelection())
child: RadioGroup<Player>( Expanded(
groupValue: _selectedPlayer, child: RadioGroup<Player>(
onChanged: (Player? value) async { groupValue: _selectedPlayer,
setState(() { onChanged: (Player? value) async {
_selectedPlayer = value; setState(() {
}); _selectedPlayer = value;
await _handleWinnerSaving(); });
}, },
child: ListView.builder( 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, itemCount: allPlayers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return CustomRadioListTile( return ScoreListTile(
text: allPlayers[index].name, text: allPlayers[index].name,
value: allPlayers[index], controller: controller[index],
onContainerTap: (value) async { );
setState(() { },
// Check if the already selected player is the same as the newly tapped player. separatorBuilder: (BuildContext context, int index) {
if (_selectedPlayer == value) { return const Padding(
// If yes deselected the player by setting it to null. padding: EdgeInsets.symmetric(vertical: 8.0),
_selectedPlayer = null; child: Divider(indent: 20),
} else {
// If no assign the newly tapped player to the selected player.
(_selectedPlayer = value);
}
});
await _handleWinnerSaving();
},
); );
}, },
), ),
), ),
),
], ],
), ),
), ),
), ),
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 /// Handles saving or removing the winner in the database
/// based on the current selection. /// based on the current selection.
Future<void> _handleWinnerSaving() async { Future<void> _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<bool> _handleWinner() async {
if (_selectedPlayer == null) { if (_selectedPlayer == null) {
await db.scoreEntryDao.removeWinner(matchId: widget.match.id); return await db.scoreEntryDao.removeWinner(matchId: widget.match.id);
} else { } else {
await db.scoreEntryDao.setWinner( return await db.scoreEntryDao.setWinner(
matchId: widget.match.id, matchId: widget.match.id,
playerId: _selectedPlayer!.id, playerId: _selectedPlayer!.id,
); );
} }
widget.onWinnerChanged?.call(); }
/// Handles saving or removing the loser in the database.
Future<bool> _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<void> _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;
} }
} }

View File

@@ -37,20 +37,16 @@ class _MatchViewState extends State<MatchView> {
Match( Match(
name: 'Skeleton match name', name: 'Skeleton match name',
game: Game( game: Game(
name: '', name: 'Game name',
ruleset: Ruleset.singleWinner, ruleset: Ruleset.singleWinner,
description: '',
color: GameColor.blue, color: GameColor.blue,
icon: '', icon: '',
), ),
group: Group( group: Group(
name: 'Group name', name: 'Group name',
description: '', members: List.filled(5, Player(name: 'Player')),
members: List.filled(5, Player(name: 'Player', description: '')),
), ),
winner: Player(name: 'Player', description: ''), players: [Player(name: 'Player')],
players: [Player(name: 'Player', description: '')],
notes: '',
), ),
); );
@@ -116,7 +112,7 @@ class _MatchViewState extends State<MatchView> {
Positioned( Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 20, bottom: MediaQuery.paddingOf(context).bottom + 20,
child: MainMenuButton( child: MainMenuButton(
text: 'Spiel erstellen', text: loc.create_match,
icon: RpgAwesome.clovers_card, icon: RpgAwesome.clovers_card,
onPressed: () async { onPressed: () async {
Navigator.push( Navigator.push(

View File

@@ -152,8 +152,8 @@ class _StatisticsViewState extends State<StatisticsView> {
// Getting the winners // Getting the winners
for (var match in matches) { for (var match in matches) {
final winner = match.winner; final mvps = match.mvp;
if (winner != null) { for (var winner in mvps) {
final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id); final index = winCounts.indexWhere((entry) => entry.$1.id == winner.id);
// -1 means winner not found in winCounts // -1 means winner not found in winCounts
if (index != -1) { if (index != -1) {
@@ -179,8 +179,7 @@ class _StatisticsViewState extends State<StatisticsView> {
final playerId = winCounts[i].$1.id; final playerId = winCounts[i].$1.id;
final player = players.firstWhere( final player = players.firstWhere(
(p) => p.id == playerId, (p) => p.id == playerId,
orElse: () => orElse: () => Player(id: playerId, name: loc.not_available),
Player(id: playerId, name: loc.not_available, description: ''),
); );
winCounts[i] = (player, winCounts[i].$2); winCounts[i] = (player, winCounts[i].$2);
} }

View File

@@ -63,7 +63,7 @@ class _PlayerSelectionState extends State<PlayerSelection> {
/// Skeleton data used while loading players. /// Skeleton data used while loading players.
late final List<Player> skeletonData = List.filled( late final List<Player> skeletonData = List.filled(
7, 7,
Player(name: 'Player 0', description: ''), Player(name: 'Player 0'),
); );
@override @override

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:tallee/core/common.dart'; import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart'; import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/match.dart';
import 'package:tallee/l10n/generated/app_localizations.dart'; import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart'; import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -44,7 +45,6 @@ class _MatchTileState extends State<MatchTile> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final match = widget.match; final match = widget.match;
final group = match.group; final group = match.group;
final winner = match.winner;
final players = [...match.players] final players = [...match.players]
..sort((a, b) => a.name.compareTo(b.name)); ..sort((a, b) => a.name.compareTo(b.name));
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
@@ -79,8 +79,7 @@ class _MatchTileState extends State<MatchTile> {
], ],
), ),
const SizedBox(height: 8), // Group Info
if (group != null) ...[ if (group != null) ...[
Row( Row(
children: [ children: [
@@ -95,7 +94,7 @@ class _MatchTileState extends State<MatchTile> {
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 4),
] else if (widget.compact) ...[ ] else if (widget.compact) ...[
Row( Row(
children: [ children: [
@@ -110,10 +109,69 @@ class _MatchTileState extends State<MatchTile> {
), ),
], ],
), ),
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( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 8, vertical: 8,
@@ -129,15 +187,11 @@ class _MatchTileState extends State<MatchTile> {
), ),
child: Row( child: Row(
children: [ children: [
const Icon( getMvpIcon(),
Icons.emoji_events,
size: 20,
color: Colors.amber,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'${loc.winner}: ${winner.name}', getMvpText(loc),
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -189,6 +243,7 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
// Players List
if (players.isNotEmpty && widget.compact == false) ...[ if (players.isNotEmpty && widget.compact == false) ...[
Text( Text(
loc.players, loc.players,
@@ -234,4 +289,44 @@ class _MatchTileState extends State<MatchTile> {
return '${loc.created_on} ${DateFormat.yMMMd(Localizations.localeOf(context).toString()).format(dateTime)}'; 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);
}
}
} }

View File

@@ -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,
),
),
),
),
),
],
),
);
}
}

View File

@@ -12,6 +12,7 @@ import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart'; import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/score_entry.dart';
import 'package:tallee/data/models/team.dart'; import 'package:tallee/data/models/team.dart';
class DataTransferService { class DataTransferService {
@@ -36,59 +37,12 @@ class DataTransferService {
final games = await db.gameDao.getAllGames(); final games = await db.gameDao.getAllGames();
final teams = await db.teamDao.getAllTeams(); final teams = await db.teamDao.getAllTeams();
// Construct a JSON representation of the data in normalized format
final Map<String, dynamic> jsonMap = { final Map<String, dynamic> jsonMap = {
'players': players.map((p) => p.toJson()).toList(), 'players': players.map((player) => player.toJson()).toList(),
'games': games.map((g) => g.toJson()).toList(), 'games': games.map((game) => game.toJson()).toList(),
'groups': groups 'groups': groups.map((group) => group.toJson()).toList(),
.map( 'teams': teams.map((team) => team.toJson()).toList(),
(g) => { 'matches': matches.map((match) => match.toJson()).toList(),
'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(),
}; };
return json.encode(jsonMap); return json.encode(jsonMap);
@@ -105,7 +59,7 @@ class DataTransferService {
) async { ) async {
try { try {
final bytes = Uint8List.fromList(utf8.encode(jsonString)); final bytes = Uint8List.fromList(utf8.encode(jsonString));
final path = await FilePicker.platform.saveFile( final path = await FilePicker.saveFile(
fileName: '$fileName.json', fileName: '$fileName.json',
bytes: bytes, bytes: bytes,
); );
@@ -126,7 +80,7 @@ class DataTransferService {
static Future<ImportResult> importData(BuildContext context) async { static Future<ImportResult> importData(BuildContext context) async {
final db = Provider.of<AppDatabase>(context, listen: false); final db = Provider.of<AppDatabase>(context, listen: false);
final path = await FilePicker.platform.pickFiles( final path = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: ['json'], allowedExtensions: ['json'],
); );
@@ -284,6 +238,15 @@ class DataTransferService {
? DateTime.parse(map['endedAt'] as String) ? DateTime.parse(map['endedAt'] as String)
: null; : null;
final notes = map['notes'] as String? ?? ''; final notes = map['notes'] as String? ?? '';
final scoresJson = map['scores'] as Map<String, dynamic>? ?? {};
final scores = scoresJson.map(
(key, value) => MapEntry(
key,
value != null
? ScoreEntry.fromJson(value as Map<String, dynamic>)
: null,
),
);
// Link attributes to objects // Link attributes to objects
final game = gamesMap[gameId] ?? getFallbackGame(); final game = gamesMap[gameId] ?? getFallbackGame();
@@ -305,6 +268,7 @@ class DataTransferService {
createdAt: createdAt, createdAt: createdAt,
endedAt: endedAt, endedAt: endedAt,
notes: notes, notes: notes,
scores: scores,
); );
}).toList(); }).toList();
} }

View File

@@ -7,18 +7,18 @@ environment:
sdk: ^3.8.1 sdk: ^3.8.1
dependencies: dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
clock: ^1.1.2 clock: ^1.1.2
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
drift: ^2.27.0 drift: ^2.27.0
drift_flutter: ^0.2.4 drift_flutter: ^0.2.4
file_picker: ^10.3.6 file_picker: ^11.0.2
file_saver: ^0.3.1 file_saver: ^0.3.1
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
fluttericon: ^2.0.0 fluttericon: ^2.0.0
font_awesome_flutter: ^10.12.0 font_awesome_flutter: ^11.0.0
intl: any intl: any
json_schema: ^5.2.2 json_schema: ^5.2.2
package_info_plus: ^9.0.0 package_info_plus: ^9.0.0
@@ -33,7 +33,7 @@ dev_dependencies:
sdk: flutter sdk: flutter
build_runner: ^2.7.0 build_runner: ^2.7.0
dart_pubspec_licenses: ^3.0.14 dart_pubspec_licenses: ^3.0.14
drift_dev: ^2.29.0 drift_dev: ^2.27.0
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter: flutter:

View File

@@ -29,10 +29,10 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
testGroup1 = Group( testGroup1 = Group(
name: 'Test Group', name: 'Test Group',
description: '', description: '',

View File

@@ -8,6 +8,7 @@ import 'package:tallee/data/models/game.dart';
import 'package:tallee/data/models/group.dart'; import 'package:tallee/data/models/group.dart';
import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart'; import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/score_entry.dart';
void main() { void main() {
late AppDatabase database; late AppDatabase database;
@@ -36,11 +37,11 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
testPlayer5 = Player(name: 'Eve', description: ''); testPlayer5 = Player(name: 'Eve');
testGroup1 = Group( testGroup1 = Group(
name: 'Test Group 1', name: 'Test Group 1',
description: '', description: '',
@@ -63,29 +64,24 @@ void main() {
game: testGame, game: testGame,
group: testGroup1, group: testGroup1,
players: [testPlayer4, testPlayer5], players: [testPlayer4, testPlayer5],
winner: testPlayer4, scores: {testPlayer4.id: ScoreEntry(score: 1)},
notes: '',
); );
testMatch2 = Match( testMatch2 = Match(
name: 'Second Test Match', name: 'Second Test Match',
game: testGame, game: testGame,
group: testGroup2, group: testGroup2,
players: [testPlayer1, testPlayer2, testPlayer3], players: [testPlayer1, testPlayer2, testPlayer3],
winner: testPlayer2,
notes: '',
); );
testMatchOnlyPlayers = Match( testMatchOnlyPlayers = Match(
name: 'Test Match with Players', name: 'Test Match with Players',
game: testGame, game: testGame,
players: [testPlayer1, testPlayer2, testPlayer3], players: [testPlayer1, testPlayer2, testPlayer3],
winner: testPlayer3,
notes: '',
); );
testMatchOnlyGroup = Match( testMatchOnlyGroup = Match(
name: 'Test Match with Group', name: 'Test Match with Group',
game: testGame, game: testGame,
group: testGroup2, group: testGroup2,
notes: '', players: testGroup2.members,
); );
}); });
await database.playerDao.addPlayersAsList( await database.playerDao.addPlayersAsList(
@@ -289,8 +285,8 @@ void main() {
matchId: testMatch1.id, matchId: testMatch1.id,
); );
expect(fetchedMatch.winner, isNotNull); expect(fetchedMatch.mvp, isNotNull);
expect(fetchedMatch.winner!.id, testPlayer4.id); expect(fetchedMatch.mvp.first.id, testPlayer4.id);
}); });
test('Setting a winner works correctly', () async { test('Setting a winner works correctly', () async {
@@ -304,8 +300,8 @@ void main() {
final fetchedMatch = await database.matchDao.getMatchById( final fetchedMatch = await database.matchDao.getMatchById(
matchId: testMatch1.id, matchId: testMatch1.id,
); );
expect(fetchedMatch.winner, isNotNull); expect(fetchedMatch.mvp, isNotNull);
expect(fetchedMatch.winner!.id, testPlayer5.id); expect(fetchedMatch.mvp.first.id, testPlayer5.id);
}); });
test( test(

View File

@@ -33,10 +33,10 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]);
testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]);
testTeam3 = Team(name: 'Team Gamma', members: [testPlayer1, testPlayer3]); testTeam3 = Team(name: 'Team Gamma', members: [testPlayer1, testPlayer3]);
@@ -343,8 +343,16 @@ void main() {
// Verifies that teams with overlapping members are independent. // Verifies that teams with overlapping members are independent.
test('Teams with overlapping members are independent', () async { test('Teams with overlapping members are independent', () async {
// Create two matches since player_match has primary key {playerId, matchId} // Create two matches since player_match has primary key {playerId, matchId}
final match1 = Match(name: 'Match 1', game: testGame1, notes: ''); final match1 = Match(
final match2 = Match(name: 'Match 2', game: testGame2, notes: ''); 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: match1);
await database.matchDao.addMatch(match: match2); await database.matchDao.addMatch(match: match2);

View File

@@ -24,10 +24,10 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Test Player', description: ''); testPlayer1 = Player(name: 'Test Player');
testPlayer2 = Player(name: 'Second Player', description: ''); testPlayer2 = Player(name: 'Second Player');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
}); });
}); });
tearDown(() async { tearDown(() async {
@@ -348,7 +348,7 @@ void main() {
// Verifies that a player with empty string name is stored correctly. // Verifies that a player with empty string name is stored correctly.
test('Player with empty string name is stored correctly', () async { 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); await database.playerDao.addPlayer(player: emptyNamePlayer);
@@ -361,7 +361,7 @@ void main() {
// Verifies that a player with very long name is stored correctly. // Verifies that a player with very long name is stored correctly.
test('Player with very long name is stored correctly', () async { test('Player with very long name is stored correctly', () async {
final longName = 'A' * 1000; final longName = 'A' * 1000;
final longNamePlayer = Player(name: longName, description: ''); final longNamePlayer = Player(name: longName);
await database.playerDao.addPlayer(player: longNamePlayer); await database.playerDao.addPlayer(player: longNamePlayer);

View File

@@ -26,10 +26,10 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
testGroup = Group( testGroup = Group(
name: 'Test Group', name: 'Test Group',
description: '', description: '',

View File

@@ -37,12 +37,12 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testPlayer4 = Player(name: 'Diana', description: ''); testPlayer4 = Player(name: 'Diana');
testPlayer5 = Player(name: 'Eve', description: ''); testPlayer5 = Player(name: 'Eve');
testPlayer6 = Player(name: 'Frank', description: ''); testPlayer6 = Player(name: 'Frank');
testGroup = Group( testGroup = Group(
name: 'Test Group', name: 'Test Group',
description: '', description: '',
@@ -58,14 +58,13 @@ void main() {
testMatchOnlyGroup = Match( testMatchOnlyGroup = Match(
name: 'Test Match with Group', name: 'Test Match with Group',
game: testGame, game: testGame,
players: testGroup.members,
group: testGroup, group: testGroup,
notes: '',
); );
testMatchOnlyPlayers = Match( testMatchOnlyPlayers = Match(
name: 'Test Match with Players', name: 'Test Match with Players',
game: testGame, game: testGame,
players: [testPlayer4, testPlayer5, testPlayer6], players: [testPlayer4, testPlayer5, testPlayer6],
notes: '',
); );
testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]); testTeam1 = Team(name: 'Team Alpha', members: [testPlayer1, testPlayer2]);
testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]); testTeam2 = Team(name: 'Team Beta', members: [testPlayer3, testPlayer4]);
@@ -96,7 +95,7 @@ void main() {
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
); );
expect(matchHasPlayers, false); expect(matchHasPlayers, true);
await database.playerMatchDao.addPlayerToMatch( await database.playerMatchDao.addPlayerToMatch(
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
@@ -397,7 +396,6 @@ void main() {
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
teamId: testTeam1.id, teamId: testTeam1.id,
); );
expect(playersInTeam.length, 2); expect(playersInTeam.length, 2);
final playerIds = playersInTeam.map((p) => p.id).toSet(); final playerIds = playersInTeam.map((p) => p.id).toSet();
expect(playerIds.contains(testPlayer1.id), true); expect(playerIds.contains(testPlayer1.id), true);
@@ -426,18 +424,16 @@ void main() {
playerId: testPlayer1.id, playerId: testPlayer1.id,
); );
// Try to add the same player again with different score
await database.playerMatchDao.addPlayerToMatch( await database.playerMatchDao.addPlayerToMatch(
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
playerId: testPlayer1.id, playerId: testPlayer1.id,
); );
// Verify player count is still 1
final players = await database.playerMatchDao.getPlayersOfMatch( final players = await database.playerMatchDao.getPlayersOfMatch(
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
); );
expect(players?.length, 1); expect(players?.length, 3);
}); });
test( test(
@@ -546,6 +542,7 @@ void main() {
matchId: testMatchOnlyGroup.id, matchId: testMatchOnlyGroup.id,
teamId: testTeam1.id, teamId: testTeam1.id,
); );
expect(playersInTeam1.length, 2); expect(playersInTeam1.length, 2);
final team1Ids = playersInTeam1.map((p) => p.id).toSet(); final team1Ids = playersInTeam1.map((p) => p.id).toSet();
expect(team1Ids.contains(testPlayer1.id), true); expect(team1Ids.contains(testPlayer1.id), true);
@@ -568,13 +565,11 @@ void main() {
name: 'Match 1', name: 'Match 1',
game: testGame, game: testGame,
players: playersList, players: playersList,
notes: '',
); );
final match2 = Match( final match2 = Match(
name: 'Match 2', name: 'Match 2',
game: testGame, game: testGame,
players: playersList, players: playersList,
notes: '',
); );
await Future.wait([ await Future.wait([

View File

@@ -30,9 +30,9 @@ void main() {
); );
withClock(fakeClock, () { withClock(fakeClock, () {
testPlayer1 = Player(name: 'Alice', description: ''); testPlayer1 = Player(name: 'Alice');
testPlayer2 = Player(name: 'Bob', description: ''); testPlayer2 = Player(name: 'Bob');
testPlayer3 = Player(name: 'Charlie', description: ''); testPlayer3 = Player(name: 'Charlie');
testGame = Game( testGame = Game(
name: 'Test Game', name: 'Test Game',
ruleset: Ruleset.singleWinner, ruleset: Ruleset.singleWinner,
@@ -44,13 +44,11 @@ void main() {
name: 'Test Match 1', name: 'Test Match 1',
game: testGame, game: testGame,
players: [testPlayer1, testPlayer2], players: [testPlayer1, testPlayer2],
notes: '',
); );
testMatch2 = Match( testMatch2 = Match(
name: 'Test Match 2', name: 'Test Match 2',
game: testGame, game: testGame,
players: [testPlayer2, testPlayer3], players: [testPlayer2, testPlayer3],
notes: '',
); );
}); });
@@ -231,8 +229,8 @@ void main() {
); );
expect(scores.length, 2); expect(scores.length, 2);
expect(scores[testPlayer1.id]!.length, 2); expect(scores[testPlayer1.id]!, isNotNull);
expect(scores[testPlayer2.id]!.length, 1); expect(scores[testPlayer2.id]!, isNotNull);
}); });
test('getAllMatchScores() with no scores saved', () async { test('getAllMatchScores() with no scores saved', () async {

View File

@@ -64,14 +64,8 @@ void main() {
players: [testPlayer1, testPlayer2], players: [testPlayer1, testPlayer2],
notes: 'Test notes', notes: 'Test notes',
scores: { scores: {
testPlayer1.id: [ testPlayer1.id: ScoreEntry(roundNumber: 1, score: 10, change: 10),
ScoreEntry(roundNumber: 1, score: 10, change: 10), testPlayer2.id: ScoreEntry(roundNumber: 1, score: 15, change: 15),
ScoreEntry(roundNumber: 2, score: 20, change: 10),
],
testPlayer2.id: [
ScoreEntry(roundNumber: 1, score: 15, change: 15),
ScoreEntry(roundNumber: 2, score: 25, change: 10),
],
}, },
); );
}); });
@@ -302,46 +296,25 @@ void main() {
final scoresJson = matchData['scores'] as Map<String, dynamic>; final scoresJson = matchData['scores'] as Map<String, dynamic>;
expect(scoresJson, isA<Map<String, dynamic>>()); expect(scoresJson, isA<Map<String, dynamic>>());
final scores = scoresJson.map( // Verify scores are properly structured (single score per player, not list)
(playerId, scoreList) => MapEntry( expect(scoresJson[testPlayer1.id], isNotNull);
playerId, expect(scoresJson[testPlayer2.id], isNotNull);
(scoreList as List)
.map((s) => ScoreEntry.fromJson(s as Map<String, dynamic>))
.toList(),
),
);
expect(scores, isA<Map<String, List<ScoreEntry>>>()); // Parse player 1 score
final player1ScoreJson =
scoresJson[testPlayer1.id] as Map<String, dynamic>;
final player1Score = ScoreEntry.fromJson(player1ScoreJson);
expect(player1Score.roundNumber, 1);
expect(player1Score.score, 10);
expect(player1Score.change, 10);
/* Player 1 scores */ // Parse player 2 score
// General structure final player2ScoreJson =
expect(scores[testPlayer1.id], isNotNull); scoresJson[testPlayer2.id] as Map<String, dynamic>;
expect(scores[testPlayer1.id]!.length, 2); final player2Score = ScoreEntry.fromJson(player2ScoreJson);
expect(player2Score.roundNumber, 1);
// Round 1 expect(player2Score.score, 15);
expect(scores[testPlayer1.id]![0].roundNumber, 1); expect(player2Score.change, 15);
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);
}); });
testWidgets('Match without group is handled correctly', (tester) async { testWidgets('Match without group is handled correctly', (tester) async {
@@ -904,14 +877,8 @@ void main() {
'playerIds': [testPlayer1.id, testPlayer2.id], 'playerIds': [testPlayer1.id, testPlayer2.id],
'notes': testMatch.notes, 'notes': testMatch.notes,
'scores': { 'scores': {
testPlayer1.id: [ testPlayer1.id: {'roundNumber': 1, 'score': 10, 'change': 10},
{'roundNumber': 1, 'score': 10, 'change': 10}, testPlayer2.id: {'roundNumber': 1, 'score': 15, 'change': 15},
{'roundNumber': 2, 'score': 20, 'change': 10},
],
testPlayer2.id: [
{'roundNumber': 1, 'score': 15, 'change': 15},
{'roundNumber': 2, 'score': 25, 'change': 10},
],
}, },
'createdAt': testMatch.createdAt.toIso8601String(), 'createdAt': testMatch.createdAt.toIso8601String(),
'endedAt': null, 'endedAt': null,