diff --git a/lib/core/common.dart b/lib/core/common.dart index fc61a94..312e3fa 100644 --- a/lib/core/common.dart +++ b/lib/core/common.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:fluttericon/rpg_awesome_icons.dart'; import 'package:tallee/core/enums.dart'; import 'package:tallee/data/models/match.dart'; import 'package:tallee/data/models/player.dart'; @@ -18,6 +19,8 @@ String translateRulesetToString(Ruleset ruleset, BuildContext context) { return loc.single_loser; case Ruleset.multipleWinners: return loc.multiple_winners; + case Ruleset.placement: + return loc.placement; } } @@ -79,6 +82,8 @@ IconData getRulesetIcon(Ruleset ruleset) { return Icons.sentiment_dissatisfied; case Ruleset.multipleWinners: return Icons.group; + case Ruleset.placement: + return RpgAwesome.podium; } } 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/dao/score_entry_dao.dart b/lib/data/dao/score_entry_dao.dart index 9c4e01d..cf6a449 100644 --- a/lib/data/dao/score_entry_dao.dart +++ b/lib/data/dao/score_entry_dao.dart @@ -353,4 +353,19 @@ class ScoreEntryDao extends DatabaseAccessor return await deleteAllScoresForMatch(matchId: matchId); } } + + /// Sets the placement for each player in a match. + /// The highest score is assigned to the first player, the second highest to the second player, and so on. + Future setPlacements({ + required String matchId, + required List players, + }) async { + for (int i = 0; i < players.length; i++) { + await db.scoreEntryDao.addScore( + matchId: matchId, + playerId: players[i].id, + entry: ScoreEntry(roundNumber: 0, score: players.length - i, change: 0), + ); + } + } } 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 4033cfe..5492bab 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -44,6 +44,7 @@ }, "delete_group": "Gruppe löschen", "delete_match": "Spiel löschen", + "drag_to_set_placement": "Ziehen um Platzierung zu setzen", "description": "Beschreibung", "edit_game": "Spielvorlage bearbeiten", "edit_group": "Gruppe bearbeiten", @@ -97,6 +98,8 @@ "none": "Kein", "none_group": "Keine", "not_available": "Nicht verfügbar", + "placement": "Platzierung", + "place": "Platz", "played_matches": "Gespielte Spiele", "player_name": "Spieler:innenname", "players": "Spieler:innen", @@ -110,6 +113,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 9bc7318..7fb944b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -45,6 +45,7 @@ }, "delete_group": "Delete Group", "delete_match": "Delete Match", + "drag_to_set_placement": "Drag to set placement", "description": "Description", "edit_game": "Edit Game", "edit_group": "Edit Group", @@ -98,6 +99,8 @@ "none": "None", "none_group": "None", "not_available": "Not available", + "placement": "Placement", + "place": "place", "played_matches": "Played Matches", "player_name": "Player name", "players": "Players", @@ -110,6 +113,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 749c8ed..bfdb659 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -320,6 +320,12 @@ abstract class AppLocalizations { /// **'Delete Match'** String get delete_match; + /// No description provided for @drag_to_set_placement. + /// + /// In en, this message translates to: + /// **'Drag to set placement'** + String get drag_to_set_placement; + /// No description provided for @description. /// /// In en, this message translates to: @@ -638,6 +644,18 @@ abstract class AppLocalizations { /// **'Not available'** String get not_available; + /// No description provided for @placement. + /// + /// In en, this message translates to: + /// **'Placement'** + String get placement; + + /// No description provided for @place. + /// + /// In en, this message translates to: + /// **'place'** + String get place; + /// No description provided for @played_matches. /// /// In en, this message translates to: @@ -710,6 +728,12 @@ abstract class AppLocalizations { /// **'Traditional ruleset: the player with the most points wins.'** String get ruleset_most_points; + /// No description provided for @ruleset_placement. + /// + /// In en, this message translates to: + /// **'Players can be arranged in an order, which reflects their placement.'** + String get ruleset_placement; + /// No description provided for @ruleset_single_loser. /// /// 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 4883272..8567ba0 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -131,6 +131,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get delete_match => 'Spiel löschen'; + @override + String get drag_to_set_placement => 'Ziehen um Platzierung zu setzen'; + @override String get description => 'Beschreibung'; @@ -295,6 +298,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get not_available => 'Nicht verfügbar'; + @override + String get placement => 'Platzierung'; + + @override + String get place => 'Platz'; + @override String get played_matches => 'Gespielte Spiele'; @@ -333,6 +342,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 b107caa..04e68b4 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -131,6 +131,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 description => 'Description'; @@ -295,6 +298,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get not_available => 'Not available'; + @override + String get placement => 'Placement'; + + @override + String get place => 'place'; + @override String get played_matches => 'Played Matches'; @@ -333,6 +342,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 26f0f2b..86c26c6 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 @@ -275,7 +275,7 @@ class _MatchDetailViewState extends State { children: getSingleResultRow(loc), ); } else { - return getScoreResultWidget(loc); + return getMultiResultRows(loc); } } @@ -325,52 +325,113 @@ class _MatchDetailViewState extends State { } } - /// Returns the result widget for scores - Widget getScoreResultWidget(AppLocalizations loc) { + /// Returns the result widget for scores or placement + Widget getMultiResultRows(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), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: CustomTheme.primaryColor, - ), - ), + getResultValueText(loc, i, playerScores[i].$2), ], ), ], ); } + Widget getResultValueText(AppLocalizations loc, int index, int score) { + final ruleset = match.game.ruleset; + + if (ruleset == Ruleset.placement) { + return Text( + getPlacementText(context, index + 1), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: getPlacementTextcolor(index), + ), + ); + } else { + return Text( + getPointLabel(loc, score), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: CustomTheme.primaryColor, + ), + ); + } + } + + Color getPlacementTextcolor(int placement) { + switch (placement) { + case 0: + return const Color(0xFFFFBF00); + case 1: + return const Color(0xBBFFFFFF); + case 2: + return const Color(0xFFCD7F32); + default: + return CustomTheme.textColor; + } + } + // Returns if the result can be displayed in a single row bool isSingleRowResult() { return match.game.ruleset == Ruleset.singleWinner || match.game.ruleset == Ruleset.singleLoser; } + String getPlacementText(BuildContext context, int rank) { + final loc = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).languageCode; + + if (locale == 'de') { + return '$rank. ${loc.place}'; + } + + return '${_ordinalEn(rank)} ${loc.place}'; + } + + String _ordinalEn(int number) { + if (number % 100 >= 11 && number % 100 <= 13) { + return '${number}th'; + } + + switch (number % 10) { + case 1: + return '${number}st'; + case 2: + return '${number}nd'; + case 3: + return '${number}rd'; + default: + return '${number}th'; + } + } + void updateScoresForCurrentMatch() { db.scoreEntryDao .getAllMatchScores(matchId: match.id) 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 2c6976e..61b2a55 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 @@ -11,6 +11,7 @@ import 'package:tallee/presentation/widgets/buttons/custom_width_button.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/custom_radio_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/live_edit_list_tile.dart'; import 'package:tallee/presentation/widgets/tiles/match_result_view/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 @@ -50,7 +51,7 @@ class _MatchResultViewState extends State { @override void initState() { db = Provider.of(context, listen: false); - ruleset = Ruleset.highestScore; //widget.match.game.ruleset; + ruleset = widget.match.game.ruleset; canSave = !rulesetSupportsScoreEntry(); allPlayers = widget.match.players; @@ -73,6 +74,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(); } @@ -106,7 +113,7 @@ class _MatchResultViewState extends State { child: Column( children: [ Expanded( - child: isLiveEditMode && rulesetSupportsScoreEntry() + child: isLiveEditMode // Live Edit Mode ? ListView.builder( itemCount: allPlayers.length, @@ -122,7 +129,7 @@ class _MatchResultViewState extends State { ); }, ) - // Normal Mode + // Normal Container : Container( margin: const EdgeInsets.symmetric( horizontal: 12, @@ -161,6 +168,7 @@ class _MatchResultViewState extends State { }); }, child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), itemCount: allPlayers.length, itemBuilder: (context, index) { return CustomRadioListTile( @@ -183,6 +191,7 @@ class _MatchResultViewState extends State { ), ), ), + // Show score entry if (rulesetSupportsScoreEntry()) Expanded( @@ -205,6 +214,111 @@ class _MatchResultViewState extends State { }, ), ), + + // Show draggable placement list + if (rulesetSupportsPlacement()) + Expanded( + child: Row( + children: [ + // Placement indicators + 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: BoxDecoration( + color: + CustomTheme.boxBorderColor, + borderRadius: CustomTheme + .standardBorderRadiusAll, + ), + alignment: Alignment.center, + height: 50, + width: 50, + child: Text( + ' #${i + 1} ', + style: const TextStyle( + color: CustomTheme.textColor, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + + // Drag list + Expanded( + child: ReorderableListView.builder( + physics: + const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + proxyDecorator: (child, index, animation) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final alpha = + (Curves.easeInOut.transform( + animation.value, + ) * + 40) + .toInt(); + return Stack( + children: [ + child!, + Positioned.fill( + left: 4, + top: 4, + right: 4, + bottom: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white + .withAlpha(alpha), + borderRadius: CustomTheme + .standardBorderRadiusAll, + ), + ), + ), + ], + ); + }, + ); + }, + 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, + icon: Icons.drag_handle, + ); + }, + ), + ), + ], + ), + ), ], ), ), @@ -266,6 +380,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(); @@ -311,12 +427,22 @@ class _MatchResultViewState extends State { } } + /// Handles saving the placement for each player in the database. + Future _handlePlacement() async { + await db.scoreEntryDao.setPlacements( + matchId: widget.match.id, + players: allPlayers, + ); + } + 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; } @@ -329,4 +455,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/player_selection.dart b/lib/presentation/widgets/player_selection.dart index 0fc8ea0..cdcc2ed 100644 --- a/lib/presentation/widgets/player_selection.dart +++ b/lib/presentation/widgets/player_selection.dart @@ -196,6 +196,7 @@ class _PlayerSelectionState extends State { return TextIconListTile( text: suggestedPlayers[index].name, suffixText: getNameCountText(suggestedPlayers[index]), + icon: Icons.add, onPressed: () { setState(() { // If the player is not already selected diff --git a/lib/presentation/widgets/tiles/match_tile.dart b/lib/presentation/widgets/tiles/match_tile.dart index f939601..d034763 100644 --- a/lib/presentation/widgets/tiles/match_tile.dart +++ b/lib/presentation/widgets/tiles/match_tile.dart @@ -261,30 +261,29 @@ class _MatchTileState extends State { 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)})'; + } else if (ruleset == Ruleset.placement) { + return '${loc.winner}: ${widget.match.mvp.first.name}'; } return '${loc.winner}: n.A.'; } Icon getMvpIcon() { - const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + final icon = getRulesetIcon(widget.match.game.ruleset); switch (widget.match.game.ruleset) { case Ruleset.singleWinner: - return const Icon(Icons.emoji_events, size: 20, color: Colors.amber); + return Icon(icon, size: 20, color: Colors.amber); case Ruleset.singleLoser: - return const Icon( - Icons.sentiment_dissatisfied_outlined, - size: 20, - color: Colors.blue, - ); + return Icon(icon, size: 20, color: Colors.blue); case Ruleset.lowestScore: - return const Icon(Icons.arrow_downward, size: 20, color: Colors.orange); + return Icon(icon, 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); + return Icon(icon, size: 20, color: Colors.green); + case Ruleset.multipleWinners: + return Icon(icon, size: 20, color: Colors.amber); + case Ruleset.placement: + return Icon(icon, size: 20, color: Colors.deepOrangeAccent); } } } diff --git a/lib/presentation/widgets/tiles/text_icon_list_tile.dart b/lib/presentation/widgets/tiles/text_icon_list_tile.dart index a31f2ae..04a0803 100644 --- a/lib/presentation/widgets/tiles/text_icon_list_tile.dart +++ b/lib/presentation/widgets/tiles/text_icon_list_tile.dart @@ -10,7 +10,7 @@ class TextIconListTile extends StatelessWidget { super.key, required this.text, this.suffixText = '', - this.iconEnabled = true, + this.icon, this.onPressed, }); @@ -20,8 +20,8 @@ class TextIconListTile extends StatelessWidget { /// An optional suffix text to display after the main text. final String suffixText; - /// A boolean to determine if the icon should be displayed. - final bool iconEnabled; + /// The icon to display in the tile. + final IconData? icon; /// The callback to be invoked when the icon is pressed. final VoidCallback? onPressed; @@ -64,11 +64,8 @@ class TextIconListTile extends StatelessWidget { ), ), ), - if (iconEnabled) - GestureDetector( - onTap: onPressed, - child: const Icon(Icons.add, size: 20), - ), + if (icon != null) + GestureDetector(onTap: onPressed, child: Icon(icon, size: 20)), ], ), );