Merge pull request #114 from flixcoo/develop

Beta-Version 0.4.7
This commit is contained in:
2025-07-14 15:06:44 +02:00
committed by GitHub
11 changed files with 303 additions and 64 deletions

View File

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

View File

@@ -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<void> 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<int> updatePoints() {
List<int> 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<int> _checkHundredPointsReached() {
List<int> 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ class ActiveGameView extends StatefulWidget {
class _ActiveGameViewState extends State<ActiveGameView> {
late final GameSession gameSession;
late List<int> denseRanks;
late List<int> sortedPlayerIndices;
@override
void initState() {
@@ -32,7 +34,9 @@ class _ActiveGameViewState extends State<ActiveGameView> {
return ListenableBuilder(
listenable: gameSession,
builder: (context, _) {
List<int> 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<ActiveGameView> {
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<ActiveGameView> {
playerIndices.sort((a, b) {
int scoreA = gameSession.playerScores[a];
int scoreB = gameSession.playerScores[b];
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<int> _calculateDenseRank(
List<int> playerScores, List<int> sortedIndices) {
List<int> 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<bool> _showDeleteGameDialog() async {
return await showCupertinoDialog<bool>(
context: context,
@@ -331,6 +346,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
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<void> _removeGameSession(GameSession gameSession) async {
if (gameManager.gameExistsInGameList(gameSession.id)) {
Navigator.pop(context);

View File

@@ -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<RoundView> {
@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<RoundView> {
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<RoundView> {
backgroundColor: CupertinoColors.secondaryLabel,
title: Row(children: [
Expanded(
child: Text(
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<RoundView> {
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<RoundView> {
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<RoundView> {
onTap: () {
setState(() {
_kamikazePlayerIndex =
(_kamikazePlayerIndex == index)
(_kamikazePlayerIndex ==
originalIndex)
? null
: index;
: originalIndex;
});
},
child: Container(
@@ -240,17 +256,20 @@ class _RoundViewState extends State<RoundView> {
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<RoundView> {
children: [
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
_finishRound();
? () async {
List<int> 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<RoundView> {
if (!widget.gameSession.isGameFinished)
CupertinoButton(
onPressed: _areRoundInputsValid()
? () {
? () async {
List<int> 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<RoundView> {
);
}
/// 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<String> _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<int> _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<RoundView> {
/// 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<int> _finishRound() {
print('====================================');
print('Runde ${widget.roundNumber} beendet');
// The shown round is smaller than the newest round
@@ -381,12 +460,63 @@ class _RoundViewState extends State<RoundView> {
widget.gameSession.calculateScoredPoints(
widget.roundNumber, roundScores, _caboPlayerIndex);
}
widget.gameSession.updatePoints();
List<int> 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<void> _showBonusPopup(
BuildContext context, List<int> 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<int> bonusPlayers) {
List<String> 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

View File

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

View File

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