feat: add placement ruleset and related localization
Some checks failed
Pull Request Pipeline / test (pull_request) Successful in 45s
Pull Request Pipeline / lint (pull_request) Failing after 48s

This commit is contained in:
2026-05-09 02:08:40 +02:00
parent 044a6acbbe
commit bc997633eb
11 changed files with 176 additions and 6 deletions

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -156,6 +156,9 @@ class Match {
case Ruleset.multipleWinners:
return [];
case Ruleset.placement:
return _getPlayersWithHighestScore().take(1).toList();
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -288,34 +288,39 @@ class _MatchDetailViewState extends State<MatchDetailView> {
}
}
/// 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,

View File

@@ -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<MatchResultView> {
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<MatchResultView> {
},
),
),
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<MatchResultView> {
} 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<MatchResultView> {
}
}
/// Handles saving the placement for each player in the database.
Future<void> _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<MatchResultView> {
bool rulesetSupportsScoreEntry() {
return ruleset == Ruleset.lowestScore || ruleset == Ruleset.highestScore;
}
bool rulesetSupportsPlacement() {
return ruleset == Ruleset.placement;
}
}

View File

@@ -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(