diff --git a/README.md b/README.md index 23ec7d3..66eab9f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CABO Counter -![Version](https://img.shields.io/badge/Version-0.4.4-orange) +![Version](https://img.shields.io/badge/Version-0.4.7-orange) ![Flutter](https://img.shields.io/badge/Flutter-3.32.1-blue?logo=flutter) ![Dart](https://img.shields.io/badge/Dart-3.8.1-blue?logo=dart) ![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple) diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index 36c4c4e..d1402e5 100644 --- a/lib/data/game_session.dart +++ b/lib/data/game_session.dart @@ -235,7 +235,7 @@ class GameSession extends ChangeNotifier { /// This method updates the points of each player after a round. /// It first uses the _sumPoints() method to calculate the total points of each player. - /// Then, it checks if any player has reached 100 points. If so, it marks + /// Then, it checks if any player has reached 100 points. If so, saves their indices and marks /// that player as having reached 100 points in that corresponding [Round] object. /// If the game has the point limit activated, it first applies the /// _subtractPointsForReachingHundred() method to subtract 50 points @@ -243,10 +243,13 @@ class GameSession extends ChangeNotifier { /// It then checks if any player has exceeded 100 points. If so, it sets /// isGameFinished to true and calls the _setWinner() method to determine /// the winner. - Future updatePoints() async { + /// It returns a list of players indices who reached 100 points in the current + /// round for the [RoundView] to show a popup + List updatePoints() { + List bonusPlayers = []; _sumPoints(); if (isPointsLimitEnabled) { - _checkHundredPointsReached(); + bonusPlayers = _checkHundredPointsReached(); for (int i = 0; i < playerScores.length; i++) { if (playerScores[i] > pointLimit) { @@ -258,6 +261,7 @@ class GameSession extends ChangeNotifier { } } notifyListeners(); + return bonusPlayers; } @visibleForTesting @@ -278,15 +282,18 @@ class GameSession extends ChangeNotifier { /// Checks if a player has reached 100 points in the current round. /// If so, it updates the [scoreUpdate] List by subtracting 50 points from /// the corresponding round update. - void _checkHundredPointsReached() { + List _checkHundredPointsReached() { + List bonusPlayers = []; for (int i = 0; i < players.length; i++) { if (playerScores[i] == pointLimit) { + bonusPlayers.add(i); print('${players[i]} hat genau 100 Punkte erreicht und bekommt ' - 'deswegen 50 Punkte abgezogen'); - roundList[roundNumber - 1].scoreUpdates[i] -= 50; + 'deswegen ${(pointLimit / 2).round()} Punkte abgezogen'); + roundList[roundNumber - 1].scoreUpdates[i] -= (pointLimit / 2).round(); } } _sumPoints(); + return bonusPlayers; } /// Determines the winner of the game session. diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index b072d63..93215ed 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -73,6 +73,25 @@ "kamikaze": "Kamikaze", "done": "Fertig", "next_round": "Nächste Runde", + "bonus_points_title": "Bonus-Punkte!", + "bonus_points_message": "{playerCount, plural, =1{{names} hat exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommt deshalb {bonusPoints} Punkte abgezogen!} other{{names} haben exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommen deshalb jeweils {bonusPoints} Punkte abgezogen!}}", + "@bonus_points_message": { + "placeholders": { + "playerCount": { + "type": "int" + }, + "names": { + "type": "String" + }, + "pointLimit": { + "type": "int" + }, + "bonusPoints": { + "type": "int" + } + } + }, + "end_game": "Spiel beenden", "delete_game": "Spiel löschen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a649362..19695a5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -73,6 +73,25 @@ "kamikaze": "Kamikaze", "done": "Done", "next_round": "Next Round", + "bonus_points_title": "Bonus-Points!", + "bonus_points_message": "{playerCount, plural, =1{{names} has reached exactly the point limit of {pointLimit} points and therefore gets {bonusPoints} points deducted!} other{{names} have reached exactly the point limit of {pointLimit} points and therefore get {bonusPoints} points deducted!}}", + "@bonus_points_message": { + "placeholders": { + "playerCount": { + "type": "int" + }, + "names": { + "type": "String" + }, + "pointLimit": { + "type": "int" + }, + "bonusPoints": { + "type": "int" + } + } + }, + "end_game": "End Game", "delete_game": "Delete Game", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 2059f1b..0a902f6 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -416,6 +416,19 @@ abstract class AppLocalizations { /// **'Nächste Runde'** String get next_round; + /// No description provided for @bonus_points_title. + /// + /// In de, this message translates to: + /// **'Bonus-Punkte!'** + String get bonus_points_title; + + /// No description provided for @bonus_points_message. + /// + /// In de, this message translates to: + /// **'{playerCount, plural, =1{{names} hat exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommt deshalb {bonusPoints} Punkte abgezogen!} other{{names} haben exakt das Punktelimit von {pointLimit} Punkten erreicht und bekommen deshalb jeweils {bonusPoints} Punkte abgezogen!}}'** + String bonus_points_message( + int playerCount, String names, int pointLimit, int bonusPoints); + /// No description provided for @end_game. /// /// In de, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 068711f..7a71d00 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -178,6 +178,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get next_round => 'Nächste Runde'; + @override + String get bonus_points_title => 'Bonus-Punkte!'; + + @override + String bonus_points_message( + int playerCount, String names, int pointLimit, int bonusPoints) { + String _temp0 = intl.Intl.pluralLogic( + playerCount, + locale: localeName, + other: + '$names haben exakt das Punktelimit von $pointLimit Punkten erreicht und bekommen deshalb jeweils $bonusPoints Punkte abgezogen!', + one: + '$names hat exakt das Punktelimit von $pointLimit Punkten erreicht und bekommt deshalb $bonusPoints Punkte abgezogen!', + ); + return '$_temp0'; + } + @override String get end_game => 'Spiel beenden'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 06b5c03..4d4d663 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -175,6 +175,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get next_round => 'Next Round'; + @override + String get bonus_points_title => 'Bonus-Points!'; + + @override + String bonus_points_message( + int playerCount, String names, int pointLimit, int bonusPoints) { + String _temp0 = intl.Intl.pluralLogic( + playerCount, + locale: localeName, + other: + '$names have reached exactly the point limit of $pointLimit points and therefore get $bonusPoints points deducted!', + one: + '$names has reached exactly the point limit of $pointLimit points and therefore gets $bonusPoints points deducted!', + ); + return '$_temp0'; + } + @override String get end_game => 'End Game'; diff --git a/lib/presentation/views/active_game_view.dart b/lib/presentation/views/active_game_view.dart index 704952a..ab07804 100644 --- a/lib/presentation/views/active_game_view.dart +++ b/lib/presentation/views/active_game_view.dart @@ -20,6 +20,8 @@ class ActiveGameView extends StatefulWidget { class _ActiveGameViewState extends State { late final GameSession gameSession; + late List denseRanks; + late List sortedPlayerIndices; @override void initState() { @@ -32,7 +34,9 @@ class _ActiveGameViewState extends State { return ListenableBuilder( listenable: gameSession, builder: (context, _) { - List sortedPlayerIndices = _getSortedPlayerIndices(); + sortedPlayerIndices = _getSortedPlayerIndices(); + denseRanks = _calculateDenseRank( + gameSession.playerScores, sortedPlayerIndices); return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( middle: Text(gameSession.gameTitle), @@ -58,7 +62,7 @@ class _ActiveGameViewState extends State { return CupertinoListTile( title: Row( children: [ - _getPlacementPrefix(index), + _getPlacementTextWidget(index), const SizedBox(width: 5), Text( gameSession.players[playerIndex], @@ -266,39 +270,50 @@ class _ActiveGameViewState extends State { playerIndices.sort((a, b) { int scoreA = gameSession.playerScores[a]; int scoreB = gameSession.playerScores[b]; - return scoreA.compareTo(scoreB); + if (scoreA != scoreB) { + return scoreA.compareTo(scoreB); + } + return a.compareTo(b); }); return playerIndices; } - /// Returns a widget that displays the placement prefix based on the index. - /// First three places are represented by medals, and the rest are numbered. - /// [index] is the index of the player in the descending sorted list. - Widget _getPlacementPrefix(int index) { - switch (index) { - case 0: - return const Text( - '\u{1F947}', - style: TextStyle(fontSize: 22), - ); + /// Calculates the dense rank for a player based on their index in the sorted list of players. + List _calculateDenseRank( + List playerScores, List sortedIndices) { + List denseRanks = []; + int rank = 1; + for (int i = 0; i < sortedIndices.length; i++) { + if (i > 0) { + int prevScore = playerScores[sortedIndices[i - 1]]; + int currScore = playerScores[sortedIndices[i]]; + if (currScore != prevScore) { + rank++; + } + } + denseRanks.add(rank); + } + return denseRanks; + } + + /// Returns a text widget representing the placement text based on the given placement number. + /// [index] is the index of the player in [players] list, + Text _getPlacementTextWidget(int index) { + int placement = denseRanks[index]; + switch (placement) { case 1: - return const Text( - '\u{1F948}', - style: TextStyle(fontSize: 22), - ); + return const Text('\u{1F947}', style: TextStyle(fontSize: 22)); // 🥇 case 2: - return const Text( - '\u{1F949}', - style: TextStyle(fontSize: 22), - ); + return const Text('\u{1F948}', style: TextStyle(fontSize: 22)); // 🥈 + case 3: + return const Text('\u{1F949}', style: TextStyle(fontSize: 22)); // 🥉 default: - return Text( - ' ${index + 1}.', - style: const TextStyle(fontWeight: FontWeight.bold), - ); + return Text(' $placement.', + style: const TextStyle(fontWeight: FontWeight.bold)); } } + /// Shows a dialog to confirm deleting the game session. Future _showDeleteGameDialog() async { return await showCupertinoDialog( context: context, @@ -331,6 +346,8 @@ class _ActiveGameViewState extends State { false; } + /// Removes the game session in the game manager and navigates back to the previous screen. + /// If the game session does not exist in the game list, it shows an error dialog. Future _removeGameSession(GameSession gameSession) async { if (gameManager.gameExistsInGameList(gameSession.id)) { Navigator.pop(context); diff --git a/lib/presentation/views/round_view.dart b/lib/presentation/views/round_view.dart index 39e5cc8..7c62120 100644 --- a/lib/presentation/views/round_view.dart +++ b/lib/presentation/views/round_view.dart @@ -5,6 +5,7 @@ import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class RoundView extends StatefulWidget { final GameSession gameSession; @@ -67,6 +68,8 @@ class _RoundViewState extends State { @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final rotatedPlayers = _getRotatedPlayers(); + final originalIndices = _getOriginalIndices(); return CupertinoPageScaffold( resizeToAvoidBottomInset: false, @@ -175,9 +178,10 @@ class _RoundViewState extends State { ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: widget.gameSession.players.length, + itemCount: rotatedPlayers.length, itemBuilder: (context, index) { - final name = widget.gameSession.players[index]; + final originalIndex = originalIndices[index]; + final name = rotatedPlayers[index]; return Padding( padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 20), @@ -187,13 +191,23 @@ class _RoundViewState extends State { backgroundColor: CupertinoColors.secondaryLabel, title: Row(children: [ Expanded( - child: Text( - name, - overflow: TextOverflow.ellipsis, - )) + child: Row(children: [ + Text( + name, + overflow: TextOverflow.ellipsis, + ), + Visibility( + visible: index == 0, + child: const SizedBox(width: 10), + ), + Visibility( + visible: index == 0, + child: const Icon(FontAwesomeIcons.medal, + size: 15)) + ])) ]), subtitle: Text( - '${widget.gameSession.playerScores[index]}' + '${widget.gameSession.playerScores[originalIndex]}' ' ${AppLocalizations.of(context).points}'), trailing: Row( children: [ @@ -201,7 +215,7 @@ class _RoundViewState extends State { width: 100, child: CupertinoTextField( maxLength: 3, - focusNode: _focusNodeList[index], + focusNode: _focusNodeList[originalIndex], keyboardType: const TextInputType.numberWithOptions( signed: true, @@ -216,12 +230,13 @@ class _RoundViewState extends State { 1 ? TextInputAction.done : TextInputAction.next, - controller: _scoreControllerList[index], + controller: + _scoreControllerList[originalIndex], placeholder: AppLocalizations.of(context).points, textAlign: TextAlign.center, onSubmitted: (_) => - _focusNextTextfield(index), + _focusNextTextfield(originalIndex), onChanged: (_) => setState(() {}), ), ), @@ -230,9 +245,10 @@ class _RoundViewState extends State { onTap: () { setState(() { _kamikazePlayerIndex = - (_kamikazePlayerIndex == index) + (_kamikazePlayerIndex == + originalIndex) ? null - : index; + : originalIndex; }); }, child: Container( @@ -240,17 +256,20 @@ class _RoundViewState extends State { height: 24, decoration: BoxDecoration( shape: BoxShape.circle, - color: _kamikazePlayerIndex == index + color: _kamikazePlayerIndex == + originalIndex ? CupertinoColors.systemRed : CupertinoColors .tertiarySystemFill, border: Border.all( - color: _kamikazePlayerIndex == index + color: _kamikazePlayerIndex == + originalIndex ? CupertinoColors.systemRed : CupertinoColors.systemGrey, ), ), - child: _kamikazePlayerIndex == index + child: _kamikazePlayerIndex == + originalIndex ? const Icon( CupertinoIcons.exclamationmark, size: 16, @@ -287,9 +306,14 @@ class _RoundViewState extends State { children: [ CupertinoButton( onPressed: _areRoundInputsValid() - ? () { - _finishRound(); + ? () async { + List bonusPlayersIndices = _finishRound(); + if (bonusPlayersIndices.isNotEmpty) { + await _showBonusPopup( + context, bonusPlayersIndices); + } LocalStorageService.saveGameSessions(); + if (!context.mounted) return; Navigator.pop(context); } : null, @@ -298,12 +322,18 @@ class _RoundViewState extends State { if (!widget.gameSession.isGameFinished) CupertinoButton( onPressed: _areRoundInputsValid() - ? () { - _finishRound(); + ? () async { + List bonusPlayersIndices = + _finishRound(); + if (bonusPlayersIndices.isNotEmpty) { + await _showBonusPopup( + context, bonusPlayersIndices); + } LocalStorageService.saveGameSessions(); - if (widget.gameSession.isGameFinished) { + if (widget.gameSession.isGameFinished && + context.mounted) { Navigator.pop(context); - } else { + } else if (context.mounted) { Navigator.pop( context, widget.roundNumber + 1); } @@ -324,11 +354,60 @@ class _RoundViewState extends State { ); } + /// Gets the index of the player who won the previous round. + int _getPreviousRoundWinnerIndex() { + if (widget.roundNumber == 1) { + return 0; // If it's the first round, there's no previous round, so return 0. + } + + final previousRound = widget.gameSession.roundList[widget.roundNumber - 2]; + final scores = previousRound.scoreUpdates; + + // Find the index of the player with the minimum score + int minScore = scores[0]; + int winnerIndex = 0; + + // Iterate through the scores to find the player with the minimum score + for (int i = 1; i < scores.length; i++) { + if (scores[i] < minScore) { + minScore = scores[i]; + winnerIndex = i; + } + } + + return winnerIndex; + } + + /// Rotates the players list based on the previous round's winner. + List _getRotatedPlayers() { + final winnerIndex = _getPreviousRoundWinnerIndex(); + return [ + widget.gameSession.players[winnerIndex], + ...widget.gameSession.players.sublist(winnerIndex + 1), + ...widget.gameSession.players.sublist(0, winnerIndex) + ]; + } + + /// Gets the original indices of the players by recalculating it from the rotated list. + List _getOriginalIndices() { + final winnerIndex = _getPreviousRoundWinnerIndex(); + return [ + winnerIndex, + ...List.generate(widget.gameSession.players.length - winnerIndex - 1, + (i) => winnerIndex + i + 1), + ...List.generate(winnerIndex, (i) => i) + ]; + } + /// Focuses the next text field in the list of text fields. /// [index] is the index of the current text field. void _focusNextTextfield(int index) { - if (index < widget.gameSession.players.length - 1) { - FocusScope.of(context).requestFocus(_focusNodeList[index + 1]); + final originalIndices = _getOriginalIndices(); + final currentPos = originalIndices.indexOf(index); + + if (currentPos < originalIndices.length - 1) { + FocusScope.of(context) + .requestFocus(_focusNodeList[originalIndices[currentPos + 1]]); } else { _focusNodeList[index].unfocus(); } @@ -359,7 +438,7 @@ class _RoundViewState extends State { /// every player. If the round is the highest round played in this game, /// it expands the player score lists. At the end it updates the score /// array for the game. - void _finishRound() { + List _finishRound() { print('===================================='); print('Runde ${widget.roundNumber} beendet'); // The shown round is smaller than the newest round @@ -381,12 +460,63 @@ class _RoundViewState extends State { widget.gameSession.calculateScoredPoints( widget.roundNumber, roundScores, _caboPlayerIndex); } - widget.gameSession.updatePoints(); + List bonusPlayers = widget.gameSession.updatePoints(); if (widget.gameSession.isGameFinished == true) { print('Das Spiel ist beendet'); } else if (widget.roundNumber == widget.gameSession.roundNumber) { widget.gameSession.increaseRound(); } + return bonusPlayers; + } + + /// Shows a popup dialog with the bonus information. + Future _showBonusPopup( + BuildContext context, List bonusPlayers) async { + print('Bonus Popup wird angezeigt'); + int pointLimit = widget.gameSession.pointLimit; + int bonusPoints = (pointLimit / 2).round(); + + String resultText = + _getBonusPopupMessageString(pointLimit, bonusPoints, bonusPlayers); + + await showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: Text(AppLocalizations.of(context).bonus_points_title), + content: Text(resultText), + actions: [ + CupertinoDialogAction( + child: Text(AppLocalizations.of(context).ok), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + + /// Generates the message string for the bonus popup. + /// It takes the [pointLimit], [bonusPoints] and the list of [bonusPlayers] + /// and returns a formatted string. + String _getBonusPopupMessageString( + int pointLimit, int bonusPoints, List bonusPlayers) { + List nameList = + bonusPlayers.map((i) => widget.gameSession.players[i]).toList(); + String resultText = ''; + if (nameList.length == 1) { + resultText = AppLocalizations.of(context).bonus_points_message( + nameList.length, nameList.first, pointLimit, bonusPoints); + } else { + resultText = nameList.length == 2 + ? '${nameList[0]} & ${nameList[1]}' + : '${nameList.sublist(0, nameList.length - 1).join(', ')} & ${nameList.last}'; + resultText = AppLocalizations.of(context).bonus_points_message( + nameList.length, + resultText, + pointLimit, + bonusPoints, + ); + } + return resultText; } @override diff --git a/pubspec.yaml b/pubspec.yaml index da2c087..6773a53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: cabo_counter description: "Mobile app for the card game Cabo" publish_to: 'none' -version: 0.4.4+485 +version: 0.4.7+506 environment: sdk: ^3.5.4 diff --git a/test/data/game_session_test.dart b/test/data/game_session_test.dart index de4e284..4ca2158 100644 --- a/test/data/game_session_test.dart +++ b/test/data/game_session_test.dart @@ -114,15 +114,15 @@ void main() { expect(session.roundList[0].caboPlayerIndex, 0); }); - test('updatePoints - game not finished', () async { + test('updatePoints - game not finished', () { session.addRoundScoresToList(1, [10, 20, 30], [10, 20, 30], 0); - await session.updatePoints(); + session.updatePoints(); expect(session.isGameFinished, isFalse); }); - test('updatePoints - game finished', () async { + test('updatePoints - game finished', () { session.addRoundScoresToList(1, [101, 20, 30], [101, 20, 30], 0); - await session.updatePoints(); + session.updatePoints(); expect(session.isGameFinished, isTrue); }); @@ -154,9 +154,9 @@ void main() { expect(session.playerScores, equals([50, 0, 30])); }); - test('_setWinner via updatePoints', () async { + test('_setWinner via updatePoints', () { session.addRoundScoresToList(1, [101, 20, 30], [101, 0, 30], 1); - await session.updatePoints(); + session.updatePoints(); expect(session.winner, 'Bob'); // Bob has lowest score (20) }); });