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
analyzer:
exclude:
- lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart
linter:
rules:
avoid_print: false
@@ -12,7 +16,3 @@ linter:
unnecessary_const: true
lines_longer_than_80_chars: false
constant_identifier_names: false
analyzer:
exclude:
- lib/presentation/views/main_menu/settings_view/licenses/oss_licenses.dart

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ class PlayerMatchDao extends DatabaseAccessor<AppDatabase>
matchId: matchId,
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.
Future<Map<String, List<ScoreEntry>>> getAllMatchScores({
Future<Map<String, ScoreEntry?>> getAllMatchScores({
required String matchId,
}) async {
final query = select(scoreEntryTable)
..where((s) => s.matchId.equals(matchId));
final result = await query.get();
final Map<String, List<ScoreEntry>> scoresByPlayer = {};
final Map<String, ScoreEntry?> scoresByPlayer = {};
for (final row in result) {
final score = ScoreEntry(
roundNumber: row.roundNumber,
score: row.score,
change: row.change,
);
scoresByPlayer.putIfAbsent(row.playerId, () => []).add(score);
scoresByPlayer[row.playerId] = score;
}
return scoresByPlayer;
@@ -235,22 +235,29 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
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 {
final query = select(scoreEntryTable)
..where((s) => s.matchId.equals(matchId))
..orderBy([(s) => OrderingTerm.desc(s.score)])
..limit(1);
final result = await query.getSingleOrNull();
final query =
select(scoreEntryTable).join([
innerJoin(
db.playerTable,
db.playerTable.id.equalsExp(scoreEntryTable.playerId),
),
])..where(
scoreEntryTable.matchId.equals(matchId) &
scoreEntryTable.score.equals(1),
);
if (result == null) return null;
final result = await query.get();
if (result.isEmpty) return null;
final player = await db.playerDao.getPlayerById(playerId: result.playerId);
final playerData = result.first.readTable(db.playerTable);
return Player(
id: player.id,
name: player.name,
createdAt: player.createdAt,
description: player.description,
id: playerData.id,
name: playerData.name,
createdAt: playerData.createdAt,
description: playerData.description,
);
}
@@ -295,20 +302,29 @@ class ScoreEntryDao extends DatabaseAccessor<AppDatabase>
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 {
final query = select(scoreEntryTable)
..where((s) => s.matchId.equals(matchId) & s.score.equals(0));
final result = await query.getSingleOrNull();
final query =
select(scoreEntryTable).join([
innerJoin(
db.playerTable,
db.playerTable.id.equalsExp(scoreEntryTable.playerId),
),
])..where(
scoreEntryTable.matchId.equals(matchId) &
scoreEntryTable.score.equals(0),
);
if (result == null) return null;
final result = await query.get();
if (result.isEmpty) return null;
final player = await db.playerDao.getPlayerById(playerId: result.playerId);
final playerData = result.first.readTable(db.playerTable);
return Player(
id: player.id,
name: player.name,
createdAt: player.createdAt,
description: player.description,
id: playerData.id,
name: playerData.name,
createdAt: playerData.createdAt,
description: playerData.description,
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -254,6 +254,12 @@ abstract class AppLocalizations {
/// **'Edit Match'**
String get edit_match;
/// Label to enter players points
///
/// In en, this message translates to:
/// **'Enter points'**
String get enter_points;
/// Button text to enter match results
///
/// In en, this message translates to:
@@ -542,6 +548,18 @@ abstract class AppLocalizations {
/// **'{count} Players'**
String players_count(int count);
/// No description provided for @point.
///
/// In en, this message translates to:
/// **'Point'**
String get point;
/// Points label
///
/// In en, this message translates to:
/// **'Points'**
String get points;
/// Privacy policy menu item
///
/// In en, this message translates to:
@@ -617,9 +635,15 @@ abstract class AppLocalizations {
/// Label to select the winner
///
/// In en, this message translates to:
/// **'Select Winner:'**
/// **'Select Winner'**
String get select_winner;
/// Label to select the loser
///
/// In en, this message translates to:
/// **'Select Loser'**
String get select_loser;
/// Shows the number of selected players
///
/// In en, this message translates to:
@@ -650,6 +674,12 @@ abstract class AppLocalizations {
/// **'Highest Score'**
String get highest_score;
/// No description provided for @loser.
///
/// In en, this message translates to:
/// **'Loser'**
String get loser;
/// No description provided for @lowest_score.
///
/// In en, this message translates to:
@@ -692,6 +722,12 @@ abstract class AppLocalizations {
/// **'This can\'t be undone.'**
String get this_cannot_be_undone;
/// No description provided for @tie.
///
/// In en, this message translates to:
/// **'Tie'**
String get tie;
/// Date format for today
///
/// In en, this message translates to:

View File

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

View File

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

View File

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

View File

@@ -255,28 +255,37 @@ class _GroupDetailViewState extends State<GroupDetailView> {
/// Determines the best player in the group based on match wins
String _getBestPlayer(List<Match> matches) {
final bestPlayerCounts = <Player, int>{};
final mvpCounts = <Player, int>{};
// Count wins for each player
for (var match in matches) {
if (match.winner != null &&
_group.members.any((m) => m.id == match.winner?.id)) {
print(match.winner);
bestPlayerCounts.update(
match.winner!,
(value) => value + 1,
ifAbsent: () => 1,
);
final mvps = match.mvp;
for (final mvpPlayer in mvps) {
if (_group.members.any((m) => m.id == mvpPlayer.id)) {
mvpCounts.update(mvpPlayer, (value) => value + 1, ifAbsent: () => 1);
}
}
}
// Sort players by win count
final sortedPlayers = bestPlayerCounts.entries.toList()
final sortedMvps = mvpCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
// Get the best player
bestPlayer = sortedPlayers.isNotEmpty ? sortedPlayers.first.key.name : '-';
if (sortedMvps.isEmpty) {
return '-';
}
return bestPlayer;
// Check if there are multiple players with the same value
final highestMvpCount = sortedMvps.first.value;
final topPlayers = sortedMvps
.where((entry) => entry.value == highestMvpCount)
.toList();
switch (topPlayers.length) {
case 0:
return '-';
case 1:
return topPlayers.first.key.name;
default:
final loc = AppLocalizations.of(context);
return loc.tie;
}
}
}

View File

@@ -36,7 +36,7 @@ class _GroupViewState extends State<GroupView> {
Group(
name: 'Skeleton Group',
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(
name: 'Skeleton Match',
game: Game(
name: '',
name: 'Skeleton Game',
ruleset: Ruleset.singleWinner,
description: '',
description: 'This is a skeleton game description.',
color: GameColor.blue,
icon: '',
),
group: Group(
name: 'Skeleton Group',
description: '',
description: 'This is a skeleton group description.',
members: [
Player(name: 'Skeleton Player 1', description: ''),
Player(name: 'Skeleton Player 2', description: ''),
Player(
name:
'Skeleton Player 1'
'',
),
Player(
name:
'Skeleton Player 2'
'',
),
],
),
notes: '',
notes: 'These are skeleton notes.',
players: [
Player(
name:
'Skeleton Player 1'
'',
),
Player(
name:
'Skeleton Player 2'
'',
),
],
),
);
@@ -125,7 +145,11 @@ class _HomeViewState extends State<HomeView> {
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> updatedWinnerInRecentMatches(String matchId) async {
Future<void> loadRecentMatches() async {
final db = Provider.of<AppDatabase>(context, listen: false);
final winner = await db.scoreEntryDao.getWinner(matchId: matchId);
final matchIndex = recentMatches.indexWhere((match) => match.id == matchId);
if (matchIndex != -1) {
setState(() {
recentMatches[matchIndex].winner = winner;
});
}
final matches = await db.matchDao.getAllMatches();
recentMatches =
(matches..sort((a, b) => b.createdAt.compareTo(a.createdAt)))
.take(2)
.toList();
}
}

View File

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

View File

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

View File

@@ -170,37 +170,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
vertical: 4,
horizontal: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
/// TODO: Implement different ruleset results display
if (match.winner != null) ...[
Text(
loc.winner,
style: const TextStyle(
fontSize: 16,
color: CustomTheme.textColor,
),
),
Text(
match.winner!.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: CustomTheme.primaryColor,
),
),
] else ...[
Text(
loc.no_results_entered_yet,
style: const TextStyle(
fontSize: 14,
color: CustomTheme.textColor,
),
),
],
],
),
child: getResultWidget(loc),
),
),
],
@@ -227,7 +197,7 @@ class _MatchDetailViewState extends State<MatchDetailView> {
text: loc.enter_results,
icon: Icons.emoji_events,
onPressed: () async {
match.winner = await Navigator.push(
await Navigator.push(
context,
adaptivePageRoute(
fullscreenDialog: true,
@@ -259,4 +229,108 @@ class _MatchDetailViewState extends State<MatchDetailView> {
});
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:provider/provider.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/db/database.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/score_entry.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart';
import 'package:tallee/presentation/widgets/tiles/custom_radio_list_tile.dart';
import 'package:tallee/presentation/widgets/tiles/score_list_tile.dart';
class MatchResultView extends StatefulWidget {
/// A view that allows selecting and saving the winner of a match
@@ -26,30 +30,61 @@ class MatchResultView extends StatefulWidget {
class _MatchResultViewState extends State<MatchResultView> {
late final AppDatabase db;
late final Ruleset ruleset;
/// List of all players who participated in the match
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
Player? _selectedPlayer;
@override
void initState() {
db = Provider.of<AppDatabase>(context, listen: false);
ruleset = widget.match.game.ruleset;
canSave = !rulesetSupportsScoreEntry();
allPlayers = widget.match.players;
allPlayers.sort((a, b) => a.name.compareTo(b.name));
if (widget.match.winner != null) {
_selectedPlayer = allPlayers.firstWhere(
(p) => p.id == widget.match.winner!.id,
);
controller = List.generate(
allPlayers.length,
(index) => TextEditingController()..addListener(() => onTextEnter()),
);
if (widget.match.mvp.isNotEmpty) {
if (rulesetSupportsWinnerSelection()) {
_selectedPlayer = allPlayers.firstWhere(
(p) => p.id == widget.match.mvp.first.id,
);
} else if (rulesetSupportsScoreEntry()) {
for (int i = 0; i < allPlayers.length; i++) {
final scoreList = widget.match.scores[allPlayers[i].id];
final score = scoreList?.score ?? 0;
controller[i].text = score.toString();
}
}
super.initState();
}
super.initState();
}
@override
void dispose() {
for (final c in controller) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
backgroundColor: CustomTheme.backgroundColor,
appBar: AppBar(
@@ -85,67 +120,169 @@ class _MatchResultViewState extends State<MatchResultView> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.select_winner,
'${getTitleForRuleset(loc)}:',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Expanded(
child: RadioGroup<Player>(
groupValue: _selectedPlayer,
onChanged: (Player? value) async {
setState(() {
_selectedPlayer = value;
});
await _handleWinnerSaving();
},
child: ListView.builder(
if (rulesetSupportsWinnerSelection())
Expanded(
child: RadioGroup<Player>(
groupValue: _selectedPlayer,
onChanged: (Player? value) async {
setState(() {
_selectedPlayer = value;
});
},
child: ListView.builder(
itemCount: allPlayers.length,
itemBuilder: (context, index) {
return CustomRadioListTile(
text: allPlayers[index].name,
value: allPlayers[index],
onContainerTap: (value) async {
setState(() {
// Check if the already selected player is the same as the newly tapped player.
if (_selectedPlayer == value) {
// If yes deselected the player by setting it to null.
_selectedPlayer = null;
} else {
// If no assign the newly tapped player to the selected player.
(_selectedPlayer = value);
}
});
},
);
},
),
),
),
if (rulesetSupportsScoreEntry())
Expanded(
child: ListView.separated(
itemCount: allPlayers.length,
itemBuilder: (context, index) {
return CustomRadioListTile(
return ScoreListTile(
text: allPlayers[index].name,
value: allPlayers[index],
onContainerTap: (value) async {
setState(() {
// Check if the already selected player is the same as the newly tapped player.
if (_selectedPlayer == value) {
// If yes deselected the player by setting it to null.
_selectedPlayer = null;
} else {
// If no assign the newly tapped player to the selected player.
(_selectedPlayer = value);
}
});
await _handleWinnerSaving();
},
controller: controller[index],
);
},
separatorBuilder: (BuildContext context, int index) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Divider(indent: 20),
);
},
),
),
),
],
),
),
),
CustomWidthButton(
text: loc.save_changes,
sizeRelativeToWidth: 0.95,
onPressed: canSave
? () async {
final ending = DateTime.now();
await db.matchDao.updateMatchEndedAt(
matchId: widget.match.id,
endedAt: ending,
);
await _handleSaving();
if (!context.mounted) return;
Navigator.of(context).pop(_selectedPlayer);
}
: null,
),
],
),
),
);
}
/// Updated [canSave] everytime a text is entered in one of the score entry fields.
void onTextEnter() {
if (rulesetSupportsScoreEntry()) {
setState(() {
canSave = controller.every((c) => c.text.isNotEmpty);
});
}
}
/// Handles saving or removing the winner in the database
/// based on the current selection.
Future<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) {
await db.scoreEntryDao.removeWinner(matchId: widget.match.id);
return await db.scoreEntryDao.removeWinner(matchId: widget.match.id);
} else {
await db.scoreEntryDao.setWinner(
return await db.scoreEntryDao.setWinner(
matchId: widget.match.id,
playerId: _selectedPlayer!.id,
);
}
widget.onWinnerChanged?.call();
}
/// Handles saving or removing the loser in the database.
Future<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(
name: 'Skeleton match name',
game: Game(
name: '',
name: 'Game name',
ruleset: Ruleset.singleWinner,
description: '',
color: GameColor.blue,
icon: '',
),
group: Group(
name: 'Group name',
description: '',
members: List.filled(5, Player(name: 'Player', description: '')),
members: List.filled(5, Player(name: 'Player')),
),
winner: Player(name: 'Player', description: ''),
players: [Player(name: 'Player', description: '')],
notes: '',
players: [Player(name: 'Player')],
),
);
@@ -116,7 +112,7 @@ class _MatchViewState extends State<MatchView> {
Positioned(
bottom: MediaQuery.paddingOf(context).bottom + 20,
child: MainMenuButton(
text: 'Spiel erstellen',
text: loc.create_match,
icon: RpgAwesome.clovers_card,
onPressed: () async {
Navigator.push(

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:tallee/core/common.dart';
import 'package:tallee/core/custom_theme.dart';
import 'package:tallee/core/enums.dart';
import 'package:tallee/data/models/match.dart';
import 'package:tallee/l10n/generated/app_localizations.dart';
import 'package:tallee/presentation/widgets/tiles/text_icon_tile.dart';
@@ -44,7 +45,6 @@ class _MatchTileState extends State<MatchTile> {
Widget build(BuildContext context) {
final match = widget.match;
final group = match.group;
final winner = match.winner;
final players = [...match.players]
..sort((a, b) => a.name.compareTo(b.name));
final loc = AppLocalizations.of(context);
@@ -79,8 +79,7 @@ class _MatchTileState extends State<MatchTile> {
],
),
const SizedBox(height: 8),
// Group Info
if (group != null) ...[
Row(
children: [
@@ -95,7 +94,7 @@ class _MatchTileState extends State<MatchTile> {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 4),
] else if (widget.compact) ...[
Row(
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(
padding: const EdgeInsets.symmetric(
vertical: 8,
@@ -129,15 +187,11 @@ class _MatchTileState extends State<MatchTile> {
),
child: Row(
children: [
const Icon(
Icons.emoji_events,
size: 20,
color: Colors.amber,
),
getMvpIcon(),
const SizedBox(width: 8),
Expanded(
child: Text(
'${loc.winner}: ${winner.name}',
getMvpText(loc),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -189,6 +243,7 @@ class _MatchTileState extends State<MatchTile> {
const SizedBox(height: 12),
],
// Players List
if (players.isNotEmpty && widget.compact == false) ...[
Text(
loc.players,
@@ -234,4 +289,44 @@ class _MatchTileState extends State<MatchTile> {
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/match.dart';
import 'package:tallee/data/models/player.dart';
import 'package:tallee/data/models/score_entry.dart';
import 'package:tallee/data/models/team.dart';
class DataTransferService {
@@ -36,59 +37,12 @@ class DataTransferService {
final games = await db.gameDao.getAllGames();
final teams = await db.teamDao.getAllTeams();
// Construct a JSON representation of the data in normalized format
final Map<String, dynamic> jsonMap = {
'players': players.map((p) => p.toJson()).toList(),
'games': games.map((g) => g.toJson()).toList(),
'groups': groups
.map(
(g) => {
'id': g.id,
'name': g.name,
'description': g.description,
'createdAt': g.createdAt.toIso8601String(),
'memberIds': (g.members).map((m) => m.id).toList(),
},
)
.toList(),
'teams': teams
.map(
(t) => {
'id': t.id,
'name': t.name,
'createdAt': t.createdAt.toIso8601String(),
'memberIds': (t.members).map((m) => m.id).toList(),
},
)
.toList(),
'matches': matches
.map(
(m) => {
'id': m.id,
'name': m.name,
'createdAt': m.createdAt.toIso8601String(),
'endedAt': m.endedAt?.toIso8601String(),
'gameId': m.game.id,
'groupId': m.group?.id,
'playerIds': m.players.map((p) => p.id).toList(),
'scores': m.scores.map(
(playerId, scores) => MapEntry(
playerId,
scores
.map(
(s) => {
'roundNumber': s.roundNumber,
'score': s.score,
'change': s.change,
},
)
.toList(),
),
),
'notes': m.notes,
},
)
.toList(),
'players': players.map((player) => player.toJson()).toList(),
'games': games.map((game) => game.toJson()).toList(),
'groups': groups.map((group) => group.toJson()).toList(),
'teams': teams.map((team) => team.toJson()).toList(),
'matches': matches.map((match) => match.toJson()).toList(),
};
return json.encode(jsonMap);
@@ -105,7 +59,7 @@ class DataTransferService {
) async {
try {
final bytes = Uint8List.fromList(utf8.encode(jsonString));
final path = await FilePicker.platform.saveFile(
final path = await FilePicker.saveFile(
fileName: '$fileName.json',
bytes: bytes,
);
@@ -126,7 +80,7 @@ class DataTransferService {
static Future<ImportResult> importData(BuildContext context) async {
final db = Provider.of<AppDatabase>(context, listen: false);
final path = await FilePicker.platform.pickFiles(
final path = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
@@ -284,6 +238,15 @@ class DataTransferService {
? DateTime.parse(map['endedAt'] as String)
: null;
final notes = map['notes'] as String? ?? '';
final scoresJson = map['scores'] as Map<String, dynamic>? ?? {};
final scores = scoresJson.map(
(key, value) => MapEntry(
key,
value != null
? ScoreEntry.fromJson(value as Map<String, dynamic>)
: null,
),
);
// Link attributes to objects
final game = gamesMap[gameId] ?? getFallbackGame();
@@ -305,6 +268,7 @@ class DataTransferService {
createdAt: createdAt,
endedAt: endedAt,
notes: notes,
scores: scores,
);
}).toList();
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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