From bc997633eba24305d9b6059857c5de507df999e1 Mon Sep 17 00:00:00 2001 From: Mathis Kirchner Date: Sat, 9 May 2026 02:08:40 +0200 Subject: [PATCH] feat: add placement ruleset and related localization --- lib/core/common.dart | 2 + lib/core/enums.dart | 2 + lib/data/models/match.dart | 3 + lib/l10n/arb/app_de.arb | 2 + lib/l10n/arb/app_en.arb | 12 +++ lib/l10n/generated/app_localizations.dart | 18 ++++ lib/l10n/generated/app_localizations_de.dart | 10 ++ lib/l10n/generated/app_localizations_en.dart | 10 ++ .../match_view/match_detail_view.dart | 17 ++-- .../match_view/match_result_view.dart | 94 +++++++++++++++++++ .../widgets/tiles/text_icon_list_tile.dart | 12 +++ 11 files changed, 176 insertions(+), 6 deletions(-) diff --git a/lib/core/common.dart b/lib/core/common.dart index 8027180..187e3c1 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -18,6 +18,8 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) { return loc.single_loser; case Ruleset.multipleWinners: return loc.multiple_winners; + case Ruleset.placement: + return loc.placement; } } diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 6b33124..605d3aa 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -32,12 +32,14 @@ enum ExportResult { success, canceled, unknownException } /// - [Ruleset.singleWinner]: The match is won by a single player. /// - [Ruleset.singleLoser]: The match has a single loser. /// - [Ruleset.multipleWinners]: Multiple players can be winners. +/// - [Ruleset.placement]: The player with the highest placement wins. enum Ruleset { highestScore, lowestScore, singleWinner, singleLoser, multipleWinners, + placement, } /// Different colors available for games diff --git a/lib/data/models/match.dart b/lib/data/models/match.dart index 9d14bb3..679f8a4 100644 --- a/lib/data/models/match.dart +++ b/lib/data/models/match.dart @@ -156,6 +156,9 @@ class Match { case Ruleset.multipleWinners: return []; + + case Ruleset.placement: + return _getPlayersWithHighestScore().take(1).toList(); } } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 46c780a..fedd7dc 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -71,6 +71,7 @@ "none": "Kein", "none_group": "Keine", "not_available": "Nicht verfügbar", + "placement": "Platzierung", "played_matches": "Gespielte Spiele", "player_name": "Spieler:innenname", "players": "Spieler:innen", @@ -85,6 +86,7 @@ "ruleset": "Regelwerk", "ruleset_least_points": "Umgekehrte Wertung: Der/die Spieler:in mit den wenigsten Punkten gewinnt.", "ruleset_most_points": "Traditionelles Regelwerk: Der/die Spieler:in mit den meisten Punkten gewinnt.", + "ruleset_placement": "Spieler:innen können in einer Reihenfolge angeordnet werden, die ihre Platzierung reflektiert.", "ruleset_single_loser": "Genau ein:e Verlierer:in wird bestimmt; der letzte Platz erhält die Strafe oder Konsequenz.", "ruleset_single_winner": "Genau ein:e Gewinner:in wird gewählt; Unentschieden werden durch einen vordefinierten Tie-Breaker aufgelöst.", "save_changes": "Änderungen speichern", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a85e1b0..82f7404 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -77,6 +77,9 @@ "@delete_match": { "description": "Button text to delete a match" }, + "@drag_to_set_placement": { + "description": "Label for dragging to set placement" + }, "@edit_group": { "description": "Button & Appbar label for editing a group" }, @@ -218,6 +221,9 @@ "@not_available": { "description": "Abbreviation for not available" }, + "@placement": { + "description": "Title for placement ruleset" + }, "@played_matches": { "description": "Label for played matches statistic" }, @@ -259,6 +265,9 @@ "@ruleset_most_points": { "description": "Description for most points ruleset" }, + "@ruleset_placement": { + "description": "Description for placement ruleset" + }, "@ruleset_single_loser": { "description": "Description for single loser ruleset" }, @@ -358,6 +367,7 @@ "delete_all_data": "Delete all data", "delete_group": "Delete Group", "delete_match": "Delete Match", + "drag_to_set_placement": "Drag to set placement", "edit_group": "Edit Group", "edit_match": "Edit Match", "enter_points": "Enter points", @@ -405,6 +415,7 @@ "none": "None", "none_group": "None", "not_available": "Not available", + "placement": "Placement", "played_matches": "Played Matches", "player_name": "Player name", "players": "Players", @@ -418,6 +429,7 @@ "ruleset": "Ruleset", "ruleset_least_points": "Inverse scoring: the player with the fewest points wins.", "ruleset_most_points": "Traditional ruleset: the player with the most points wins.", + "ruleset_placement": "Players can be arranged in an order, which reflects their placement.", "ruleset_single_loser": "Exactly one loser is determined; last place receives the penalty or consequence.", "ruleset_single_winner": "Exactly one winner is chosen; ties are resolved by a predefined tiebreaker.", "save_changes": "Save Changes", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 99c9317..f4cd87c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -242,6 +242,12 @@ abstract class AppLocalizations { /// **'Delete Match'** String get delete_match; + /// Label for dragging to set placement + /// + /// In en, this message translates to: + /// **'Drag to set placement'** + String get drag_to_set_placement; + /// Button & Appbar label for editing a group /// /// In en, this message translates to: @@ -524,6 +530,12 @@ abstract class AppLocalizations { /// **'Not available'** String get not_available; + /// Title for placement ruleset + /// + /// In en, this message translates to: + /// **'Placement'** + String get placement; + /// Label for played matches statistic /// /// In en, this message translates to: @@ -602,6 +614,12 @@ abstract class AppLocalizations { /// **'Traditional ruleset: the player with the most points wins.'** String get ruleset_most_points; + /// Description for placement ruleset + /// + /// In en, this message translates to: + /// **'Players can be arranged in an order, which reflects their placement.'** + String get ruleset_placement; + /// Description for single loser ruleset /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 51b4c62..f53261d 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -84,6 +84,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get delete_match => 'Spiel löschen'; + @override + String get drag_to_set_placement => 'Ziehen, um die Platzierung zu setzen'; + @override String get edit_group => 'Gruppe bearbeiten'; @@ -229,6 +232,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get not_available => 'Nicht verfügbar'; + @override + String get placement => 'Platzierung'; + @override String get played_matches => 'Gespielte Spiele'; @@ -272,6 +278,10 @@ class AppLocalizationsDe extends AppLocalizations { String get ruleset_most_points => 'Traditionelles Regelwerk: Der/die Spieler:in mit den meisten Punkten gewinnt.'; + @override + String get ruleset_placement => + 'Spieler:innen können in einer Reihenfolge angeordnet werden, die ihre Platzierung reflektiert.'; + @override String get ruleset_single_loser => 'Genau ein:e Verlierer:in wird bestimmt; der letzte Platz erhält die Strafe oder Konsequenz.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 2b42e47..6dcfda1 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -84,6 +84,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get delete_match => 'Delete Match'; + @override + String get drag_to_set_placement => 'Drag to set placement'; + @override String get edit_group => 'Edit Group'; @@ -229,6 +232,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get not_available => 'Not available'; + @override + String get placement => 'Placement'; + @override String get played_matches => 'Played Matches'; @@ -272,6 +278,10 @@ class AppLocalizationsEn extends AppLocalizations { String get ruleset_most_points => 'Traditional ruleset: the player with the most points wins.'; + @override + String get ruleset_placement => + 'Players can be arranged in an order, which reflects their placement.'; + @override String get ruleset_single_loser => 'Exactly one loser is determined; last place receives the penalty or consequence.'; diff --git a/lib/presentation/views/main_menu/match_view/match_detail_view.dart b/lib/presentation/views/main_menu/match_view/match_detail_view.dart index 2117b77..6f09301 100644 --- a/lib/presentation/views/main_menu/match_view/match_detail_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_detail_view.dart @@ -288,34 +288,39 @@ class _MatchDetailViewState extends State { } } - /// Returns the result widget for scores + /// Returns the result widget for scores or placement 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) { + + final ruleset = match.game.ruleset; + + if (ruleset == Ruleset.highestScore || ruleset == Ruleset.placement) { playerScores.sort((a, b) => b.$2.compareTo(a.$2)); - } else if (widget.match.game.ruleset == Ruleset.lowestScore) { + } else if (ruleset == Ruleset.lowestScore) { playerScores.sort((a, b) => a.$2.compareTo(b.$2)); } return Column( children: [ - for (var score in playerScores) + for (var i = 0; i < playerScores.length; i++) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - score.$1, + playerScores[i].$1, style: const TextStyle( fontSize: 16, color: CustomTheme.textColor, ), ), Text( - getPointLabel(loc, score.$2), + ruleset == Ruleset.placement + ? '#${i + 1}' + : getPointLabel(loc, playerScores[i].$2), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, diff --git a/lib/presentation/views/main_menu/match_view/match_result_view.dart b/lib/presentation/views/main_menu/match_view/match_result_view.dart index 1fd6780..bf85e03 100644 --- a/lib/presentation/views/main_menu/match_view/match_result_view.dart +++ b/lib/presentation/views/main_menu/match_view/match_result_view.dart @@ -10,6 +10,7 @@ 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'; +import 'package:tallee/presentation/widgets/tiles/text_icon_list_tile.dart'; class MatchResultView extends StatefulWidget { /// A view that allows selecting and saving the winner of a match @@ -68,6 +69,12 @@ class _MatchResultViewState extends State { final score = scoreList?.score ?? 0; controller[i].text = score.toString(); } + } else if (rulesetSupportsPlacement()) { + allPlayers.sort((a, b) { + final scoreA = widget.match.scores[a.id]?.score ?? 0; + final scoreB = widget.match.scores[b.id]?.score ?? 0; + return scoreB.compareTo(scoreA); + }); } super.initState(); } @@ -177,6 +184,70 @@ class _MatchResultViewState extends State { }, ), ), + if (rulesetSupportsPlacement()) + Expanded( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Column( + children: [ + for (int i = 0; i < allPlayers.length; i++) + Container( + alignment: Alignment.center, + height: 60, + child: Container( + decoration: + CustomTheme.standardBoxDecoration, + alignment: Alignment.center, + height: 50, + width: 40, + child: Text( + " #${i + 1} ", + style: const TextStyle( + color: CustomTheme.primaryColor, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: ReorderableListView.builder( + padding: EdgeInsets.zero, + proxyDecorator: (child, index, animation) { + return Material( + color: Colors.transparent, + child: child, + ); + }, + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final Player item = allPlayers.removeAt( + oldIndex, + ); + allPlayers.insert(newIndex, item); + }); + }, + itemCount: allPlayers.length, + itemBuilder: (context, index) { + return TextIconListTile( + key: ValueKey(allPlayers[index].id), + text: allPlayers[index].name, + iconEnabled: false, + ); + }, + ), + ), + ], + ), + ), ], ), ), @@ -222,6 +293,8 @@ class _MatchResultViewState extends State { } else if (ruleset == Ruleset.lowestScore || ruleset == Ruleset.highestScore) { await _handleScores(); + } else if (ruleset == Ruleset.placement) { + await _handlePlacement(); } widget.onWinnerChanged?.call(); @@ -267,12 +340,29 @@ class _MatchResultViewState extends State { } } + /// Handles saving the placement for each player in the database. + Future _handlePlacement() async { + for (int i = 0; i < allPlayers.length; i++) { + await db.scoreEntryDao.addScore( + matchId: widget.match.id, + playerId: allPlayers[i].id, + entry: ScoreEntry( + roundNumber: 0, + score: allPlayers.length - i, + change: 0, + ), + ); + } + } + String getTitleForRuleset(AppLocalizations loc) { switch (ruleset) { case Ruleset.singleWinner: return loc.select_winner; case Ruleset.singleLoser: return loc.select_loser; + case Ruleset.placement: + return loc.drag_to_set_placement; default: return loc.enter_points; } @@ -285,4 +375,8 @@ class _MatchResultViewState extends State { bool rulesetSupportsScoreEntry() { return ruleset == Ruleset.lowestScore || ruleset == Ruleset.highestScore; } + + bool rulesetSupportsPlacement() { + return ruleset == Ruleset.placement; + } } diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart index a31f2ae..f77b5c3 100644 --- a/lib/presentation/widgets/tiles/text_icon_list_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -10,6 +10,7 @@ class TextIconListTile extends StatelessWidget { super.key, required this.text, this.suffixText = '', + this.prefixText = '', this.iconEnabled = true, this.onPressed, }); @@ -20,6 +21,9 @@ class TextIconListTile extends StatelessWidget { /// An optional suffix text to display after the main text. final String suffixText; + /// An optional prefix text to display before the main text. + final String prefixText; + /// A boolean to determine if the icon should be displayed. final bool iconEnabled; @@ -44,6 +48,14 @@ class TextIconListTile extends StatelessWidget { text: TextSpan( style: DefaultTextStyle.of(context).style, children: [ + TextSpan( + text: prefixText, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ), TextSpan( text: text, style: const TextStyle(