Beta-Version 0.5.3 (#136)

* Updated createGameView ListBuilder

* Added ReorderableListView

* Increment build no

* Fixed bug with wrong medal icon

* change not equal to greater than

* Updated bool var

* Fixed deletion error

* Small translation improvements

* Implemented first version of point overview

* Visual improvements on table

* Added details and sum row

* Updated strings

* Implemented new strings

* Refactoring

* Updated graph displayment

* Moved new views to statistics section

* Added seperator in main menu

* Renaming

* Updated sign

* Updated colors & class name

* Removed empty line

* Updated round index

* Updated types

* Added new kamikaze button and bundles navigation functionality

* Updated lock icon

* Updated button position and design

* Removed title row and changed segmendetControl Padding

* Refactored logic and added comments

* Updated comment

* Chaned icon

* Added comment

* Removed print

* Updated colors

* Changed var name

* Removed unused strings

* Added gameMode

* Changed creation variable

* Updated mode selection

* Updated strings

* Changed mode order

* Implemented default mode selection

* Updated initState

* Removed print

* Removed print

* Removed comments

* Updated config service

* Changed create game view

* Changed icon

* Updated strings

* Updated config

* Updated mode selection logic

* Deleted getter

* Removed not used code

* Implemented reset logic for default game mode

* Updated to 0.5.0

* Hotfix: Pixel Overflow

* Changed the overall return type for gamemodes

* Updated documentation

* Fixed merge issues

* Added Custom button

* Updated strings

* Updated buttons, implemented animatedOpacity

* Keyboard still doesnt works

* Fixed keyboard behaviour

* Changed keyboard height

* Added method getGameSessionById()

* Updated gameSession class

* id gets added to gameSession class at creation

* Cleaned up file

* Added docs and dependency

* Removed toString

* Implemented null safety

* Added named parameter

* Replaced button with custom button

* Updated key

* Updated addGameSessionMethod

* Update README.md

* Added Strings for popup

* Implemented popup & confetti

* Extracted code to method _playFinishAnimation()

* Replaced tenary operator with Visibility Widget

* Replaced tenary operator with Visibility Widget

* Used variable again

* Added delays in constants.dart

* Removed confetti button

* Updated strings

* Removed print

* Added dispose for confettiController

* Implemented missing constant in code

* Updated gameSession logic so more than one player can be winner

* Updated strings

* Updated winner popup

* game names now can have up to 20 chars

* Updated strings

* Added sized box for visual enhancement

* Centered the add player button and made it wider

* New created player textfields get automatically focused

* Added focus nodes for autofocus and navigation between textfields

* Updated version number

* Updated game title textfield with focus node and textaction

* Added focusnodes to dispose

* Update README.md

* Fixed bug with no popup shown

* Fixed bug with out of range error

* Updated listener notification
This commit is contained in:
2025-07-21 13:29:25 +02:00
committed by GitHub
parent c19ce71198
commit d627f33579
24 changed files with 1503 additions and 799 deletions

View File

@@ -1,6 +1,6 @@
# CABO Counter # CABO Counter
![Version](https://img.shields.io/badge/Version-0.4.7-orange) ![Version](https://img.shields.io/badge/Version-0.5.3-orange)
![Flutter](https://img.shields.io/badge/Flutter-3.32.1-blue?logo=flutter) ![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) ![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) ![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple)
@@ -16,21 +16,25 @@ Cabo Counter is an intuitive Flutter-based mobile application designed to enhanc
## ✨ Features ## ✨ Features
- 🆕 Create new games with customizable rules
- 👥 Support for 2-5 players - 👥 Support for 2-5 players
- ⚖️ Two game modes: - ⚖️ Two game modes:
- **100 Points Mode** (Standard) - **Point Limit Mode**: Play until a certain point limit is reached
- **Infinite Mode** (Casual play) - **Unlimited Mode**: Play without an limit and end the round at any point
- 🔢 Automatic score calculation with: - 🔢 Automatic score calculation with:
- Kamikaze rule handling - Falsly calling Cabo
- Exact 100-point bonus (score halving) - Exact 100-point bonus (score halving)
- 📊 Round history tracking - Kamikaze rule handling
- 📊 Round history tracking via graph and table
- 🎨 Customizable
- Change the default settings for point limits and cabo penaltys
- Choose a default game mode for every new created game
- 💿 Im- and exporting certain games or the whole app data
## 🚀 Getting Started ## 🚀 Getting Started
### Prerequisites ### Prerequisites
- Flutter 3.24.5+ - Flutter 3.32.1+
- Dart 3.5.4+ - Dart 3.8.1+
### Installation ### Installation
@@ -43,18 +47,22 @@ flutter run
## 🎮 Usage ## 🎮 Usage
1. **Start New Game** 1. **Start a new game**
- Choose game mode (100 Points or Infinite) - Click the "+"-Button
- Choose a game title and a game mode
- Add 2-5 players - Add 2-5 players
2. **Gameplay** 2. **Gameplay**
- Track rounds with automatic scoring - Open the first round
- Handle special rules (Kamikaze, exact 100 points) - Choose the player who called Cabo
- View real-time standings - Enter the points of every player
- If given: Choose a Kamikaze player
- Navigate to the next round or back to the overview
- Let the app calculate all points for you
3. **Round Management** 3. **Statistics**
- Automatic winner detection - View the progress graph for the game
- Penalty point calculation - Get a detailed table overview for every points made or lost
- Game-over detection (100 Points mode) - Game-over detection (100 Points mode)
## 🃏 Key Rules Overview ## 🃏 Key Rules Overview
@@ -67,7 +75,8 @@ flutter run
- Exact 100 points: Score halved - Exact 100 points: Score halved
### Game End ### Game End
- First player ≥101 points triggers final scoring - First player ≥100 points triggers final scoring
- In unlimited mode you can end the game via the End Game Button
- Lowest total score wins - Lowest total score wins
## 🤝 Contributing ## 🤝 Contributing

View File

@@ -19,4 +19,13 @@ class Constants {
remindDays: 45, remindDays: 45,
minLaunches: 15, minLaunches: 15,
remindLaunches: 40); remindLaunches: 40);
/// Delay in milliseconds before a pop-up appears.
static const int popUpDelay = 300;
/// Delay in milliseconds before the round view appears after the previous one is closed.
static const int roundViewDelay = 600;
/// Duration in milliseconds for the fade-in animation of texts.
static const int fadeInDuration = 300;
} }

View File

@@ -4,7 +4,9 @@ class CustomTheme {
static Color white = CupertinoColors.white; static Color white = CupertinoColors.white;
static Color primaryColor = CupertinoColors.systemGreen; static Color primaryColor = CupertinoColors.systemGreen;
static Color backgroundColor = const Color(0xFF101010); static Color backgroundColor = const Color(0xFF101010);
static Color backgroundTintColor = CupertinoColors.darkBackgroundGray; static Color mainElementBackgroundColor = CupertinoColors.darkBackgroundGray;
static Color playerTileColor = CupertinoColors.secondaryLabel;
static Color buttonBackgroundColor = const Color(0xFF202020);
// Line Colors for GraphView // Line Colors for GraphView
static const Color graphColor1 = Color(0xFFF44336); static const Color graphColor1 = Color(0xFFF44336);
@@ -13,6 +15,10 @@ class CustomTheme {
static const Color graphColor4 = Color(0xFF9C27B0); static const Color graphColor4 = Color(0xFF9C27B0);
static final Color graphColor5 = primaryColor; static final Color graphColor5 = primaryColor;
// Colors for PointsView
static Color pointLossColor = primaryColor;
static const Color pointGainColor = Color(0xFFF44336);
static TextStyle modeTitle = TextStyle( static TextStyle modeTitle = TextStyle(
color: primaryColor, color: primaryColor,
fontSize: 20, fontSize: 20,

View File

@@ -1,5 +1,6 @@
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class GameManager extends ChangeNotifier { class GameManager extends ChangeNotifier {
@@ -10,17 +11,25 @@ class GameManager extends ChangeNotifier {
/// sorts the list in descending order based on the creation date, and notifies listeners of the change. /// sorts the list in descending order based on the creation date, and notifies listeners of the change.
/// It also saves the updated game sessions to local storage. /// It also saves the updated game sessions to local storage.
/// Returns the index of the newly added session in the sorted list. /// Returns the index of the newly added session in the sorted list.
Future<int> addGameSession(GameSession session) async { int addGameSession(GameSession session) {
session.addListener(() { session.addListener(() {
notifyListeners(); // Propagate session changes notifyListeners(); // Propagate session changes
}); });
gameList.add(session); gameList.add(session);
gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt)); gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt));
notifyListeners(); notifyListeners();
await LocalStorageService.saveGameSessions(); LocalStorageService.saveGameSessions();
return gameList.indexOf(session); return gameList.indexOf(session);
} }
/// Retrieves a game session by its id.
/// Takes a String [id] as input. It searches the `gameList` for a session
/// with a matching id and returns it if found.
/// If no session is found, it returns null.
GameSession? getGameSessionById(String id) {
return gameList.firstWhereOrNull((session) => session.id == id);
}
/// Removes a game session from the list and sorts it by creation date. /// Removes a game session from the list and sorts it by creation date.
/// Takes a [index] as input. It then removes the session at the specified index from the `gameList`, /// Takes a [index] as input. It then removes the session at the specified index from the `gameList`,
/// sorts the list in descending order based on the creation date, and notifies listeners of the change. /// sorts the list in descending order based on the creation date, and notifies listeners of the change.
@@ -60,6 +69,7 @@ class GameManager extends ChangeNotifier {
gameList[index].roundNumber--; gameList[index].roundNumber--;
gameList[index].isGameFinished = true; gameList[index].isGameFinished = true;
gameList[index].setWinner();
notifyListeners(); notifyListeners();
LocalStorageService.saveGameSessions(); LocalStorageService.saveGameSessions();
} }

View File

@@ -13,7 +13,7 @@ import 'package:uuid/uuid.dart';
/// [isGameFinished] is a boolean indicating if the game has ended yet. /// [isGameFinished] is a boolean indicating if the game has ended yet.
/// [winner] is the name of the player who won the game. /// [winner] is the name of the player who won the game.
class GameSession extends ChangeNotifier { class GameSession extends ChangeNotifier {
late String id; final String id;
final DateTime createdAt; final DateTime createdAt;
final String gameTitle; final String gameTitle;
final List<String> players; final List<String> players;
@@ -27,6 +27,7 @@ class GameSession extends ChangeNotifier {
List<Round> roundList = []; List<Round> roundList = [];
GameSession({ GameSession({
required this.id,
required this.createdAt, required this.createdAt,
required this.gameTitle, required this.gameTitle,
required this.players, required this.players,
@@ -35,8 +36,6 @@ class GameSession extends ChangeNotifier {
required this.isPointsLimitEnabled, required this.isPointsLimitEnabled,
}) { }) {
playerScores = List.filled(players.length, 0); playerScores = List.filled(players.length, 0);
var uuid = const Uuid();
id = uuid.v1();
} }
@override @override
@@ -256,7 +255,7 @@ class GameSession extends ChangeNotifier {
isGameFinished = true; isGameFinished = true;
print('${players[i]} hat die 100 Punkte ueberschritten, ' print('${players[i]} hat die 100 Punkte ueberschritten, '
'deswegen wurde das Spiel beendet'); 'deswegen wurde das Spiel beendet');
_setWinner(); setWinner();
} }
} }
} }
@@ -299,16 +298,20 @@ class GameSession extends ChangeNotifier {
/// Determines the winner of the game session. /// Determines the winner of the game session.
/// It iterates through the player scores and finds the player /// It iterates through the player scores and finds the player
/// with the lowest score. /// with the lowest score.
void _setWinner() { void setWinner() {
int score = playerScores[0]; int minScore = playerScores.reduce((a, b) => a < b ? a : b);
String lowestPlayer = players[0]; List<String> lowestPlayers = [];
for (int i = 0; i < players.length; i++) { for (int i = 0; i < players.length; i++) {
if (playerScores[i] < score) { if (playerScores[i] == minScore) {
score = playerScores[i]; lowestPlayers.add(players[i]);
lowestPlayer = players[i];
} }
} }
winner = lowestPlayer; if (lowestPlayers.length > 1) {
winner =
'${lowestPlayers.sublist(0, lowestPlayers.length - 1).join(', ')} & ${lowestPlayers.last}';
} else {
winner = lowestPlayers.first;
}
notifyListeners(); notifyListeners();
} }

View File

@@ -55,9 +55,12 @@
"min_players_title": "Zu wenig Spieler:innen", "min_players_title": "Zu wenig Spieler:innen",
"min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden", "min_players_message": "Es müssen mindestens 2 Spieler:innen hinzugefügt werden",
"no_name_title": "Kein Name", "no_name_title": "Kein Name",
"no_name_message": "Jeder Spieler muss einen Namen haben.", "no_name_message": "Jede:r Spieler:in muss einen Namen haben.",
"select_game_mode": "Spielmodus auswählen", "select_game_mode": "Spielmodus auswählen",
"no_mode_selected": "Wähle einen Spielmodus",
"no_default_mode": "Kein Modus",
"no_default_description": "Entscheide bei jedem Spiel selber, welchen Modus du spielen möchtest.",
"point_limit_description": "Es wird so lange gespielt, bis ein:e Spieler:in mehr als {pointLimit} Punkte erreicht", "point_limit_description": "Es wird so lange gespielt, bis ein:e Spieler:in mehr als {pointLimit} Punkte erreicht",
"@point_limit_description": { "@point_limit_description": {
"placeholders": { "placeholders": {
@@ -71,6 +74,7 @@
"results": "Ergebnisse", "results": "Ergebnisse",
"who_said_cabo": "Wer hat CABO gesagt?", "who_said_cabo": "Wer hat CABO gesagt?",
"kamikaze": "Kamikaze", "kamikaze": "Kamikaze",
"who_has_kamikaze": "Wer hat Kamikaze?",
"done": "Fertig", "done": "Fertig",
"next_round": "Nächste Runde", "next_round": "Nächste Runde",
"bonus_points_title": "Bonus-Punkte!", "bonus_points_title": "Bonus-Punkte!",
@@ -92,7 +96,21 @@
} }
}, },
"end_of_game_title": "Spiel beendet",
"end_of_game_message": "{playerCount, plural, =1{{names} hat das Spiel mit {points} Punkten gewonnen. Glückwunsch!} other{{names} haben das Spiel mit {points} Punkten gewonnen. Glückwunsch!}}",
"@end_of_game_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"points": {
"type": "int"
}
}
},
"end_game": "Spiel beenden", "end_game": "Spiel beenden",
"delete_game": "Spiel löschen", "delete_game": "Spiel löschen",
"new_game_same_settings": "Neues Spiel mit gleichen Einstellungen", "new_game_same_settings": "Neues Spiel mit gleichen Einstellungen",
@@ -102,14 +120,15 @@
"end_game_title": "Spiel beenden?", "end_game_title": "Spiel beenden?",
"end_game_message": "Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.", "end_game_message": "Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.",
"game_process": "Spielverlauf", "statistics": "Statistiken",
"point_overview": "Punkteübersicht",
"scoring_history": "Spielverlauf",
"empty_graph_text": "Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.", "empty_graph_text": "Du musst mindestens eine Runde spielen, damit der Graph des Spielverlaufes angezeigt werden kann.",
"settings": "Einstellungen", "settings": "Einstellungen",
"cabo_penalty": "Cabo-Strafe", "cabo_penalty": "Cabo-Strafe",
"cabo_penalty_subtitle": "... für falsches Cabo sagen",
"point_limit": "Punkte-Limit", "point_limit": "Punkte-Limit",
"point_limit_subtitle": "... hier ist Schluss", "standard_mode": "Standard-Modus",
"reset_to_default": "Auf Standard zurücksetzen", "reset_to_default": "Auf Standard zurücksetzen",
"game_data": "Spieldaten", "game_data": "Spieldaten",
"import_data": "Spieldaten importieren", "import_data": "Spieldaten importieren",

View File

@@ -14,13 +14,13 @@
"player": "Player", "player": "Player",
"players": "Players", "players": "Players",
"name": "Name", "name": "Name",
"back": "Back", "back": "Back",
"home": "Home", "home": "Home",
"about": "About", "about": "About",
"empty_text_1": "Pretty empty here...", "empty_text_1": "Pretty empty here...",
"empty_text_2": "Add a new round using the button in the top right corner.", "empty_text_2": "Create a new game using the button in the top right.",
"delete_game_title": "Delete game?", "delete_game_title": "Delete game?",
"delete_game_message": "Are you sure you want to delete the game \"{gameTitle}\"? This action cannot be undone.", "delete_game_message": "Are you sure you want to delete the game \"{gameTitle}\"? This action cannot be undone.",
"@delete_game_message": { "@delete_game_message": {
@@ -46,19 +46,22 @@
"select_mode": "Select a mode", "select_mode": "Select a mode",
"add_player": "Add Player", "add_player": "Add Player",
"create_game": "Create Game", "create_game": "Create Game",
"max_players_title": "Maximum reached", "max_players_title": "Player Limit Reached",
"max_players_message": "A maximum of 5 players can be added.", "max_players_message": "You can add a maximum of 5 players.",
"no_gameTitle_title": "No Title", "no_gameTitle_title": "Missing Game Title",
"no_gameTitle_message": "You must enter a title for the game.", "no_gameTitle_message": "Please enter a title for your game.",
"no_mode_title": "No Mode", "no_mode_title": "Game Mode Required",
"no_mode_message": "You must select a game mode.", "no_mode_message": "Please select a game mode to continue",
"min_players_title": "Too few players", "min_players_title": "Too Few Players",
"min_players_message": "At least 2 players must be added.", "min_players_message": "At least 2 players are required to start the game.",
"no_name_title": "No Name", "no_name_title": "Missing Player Names",
"no_name_message": "Each player must have a name.", "no_name_message": "Each player must have a name.",
"select_game_mode": "Select game mode", "select_game_mode": "Select game mode",
"point_limit_description": "The game ends when a player reaches more than {pointLimit} points.", "no_mode_selected": "No mode selected",
"no_default_mode": "No default mode",
"no_default_description": "The default mode gets reset.",
"point_limit_description": "The game ends when a player scores more than {pointLimit} points.",
"@point_limit_description": { "@point_limit_description": {
"placeholders": { "placeholders": {
"pointLimit": { "pointLimit": {
@@ -66,11 +69,12 @@
} }
} }
}, },
"unlimited_description": "There is no limit. The game continues until you decide to stop.", "unlimited_description": "The game continues until you decide to stop playing",
"results": "Results", "results": "Results",
"who_said_cabo": "Who said CABO?", "who_said_cabo": "Who called Cabo?",
"kamikaze": "Kamikaze", "kamikaze": "Kamikaze",
"who_has_kamikaze": "Who has Kamikaze?",
"done": "Done", "done": "Done",
"next_round": "Next Round", "next_round": "Next Round",
"bonus_points_title": "Bonus-Points!", "bonus_points_title": "Bonus-Points!",
@@ -92,7 +96,21 @@
} }
}, },
"end_of_game_title": "End of Game",
"end_of_game_message": "{playerCount, plural, =1{{names} won the game with {points} points. Congratulations!} other{{names} won the game with {points} points. Congratulations to everyone!}}",
"@end_of_game_message": {
"placeholders": {
"playerCount": {
"type": "int"
},
"names": {
"type": "String"
},
"points": {
"type": "int"
}
}
},
"end_game": "End Game", "end_game": "End Game",
"delete_game": "Delete Game", "delete_game": "Delete Game",
"new_game_same_settings": "New Game with same Settings", "new_game_same_settings": "New Game with same Settings",
@@ -102,14 +120,15 @@
"end_game_title": "End the game?", "end_game_title": "End the game?",
"end_game_message": "Do you want to end the game? The game gets marked as finished and cannot be continued.", "end_game_message": "Do you want to end the game? The game gets marked as finished and cannot be continued.",
"game_process": "Scoring History", "statistics": "Statistics",
"point_overview": "Point Overview",
"scoring_history": "Scoring History",
"empty_graph_text": "You must play at least one round for the game progress graph to be displayed.", "empty_graph_text": "You must play at least one round for the game progress graph to be displayed.",
"settings": "Settings", "settings": "Settings",
"cabo_penalty": "Cabo Penalty", "cabo_penalty": "Cabo Penalty",
"cabo_penalty_subtitle": "... for falsely calling Cabo.",
"point_limit": "Point Limit", "point_limit": "Point Limit",
"point_limit_subtitle": "... the game ends here.", "standard_mode": "Default Mode",
"reset_to_default": "Reset to Default", "reset_to_default": "Reset to Default",
"game_data": "Game Data", "game_data": "Game Data",
"import_data": "Import Data", "import_data": "Import Data",

View File

@@ -365,7 +365,7 @@ abstract class AppLocalizations {
/// No description provided for @no_name_message. /// No description provided for @no_name_message.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
/// **'Jeder Spieler muss einen Namen haben.'** /// **'Jede:r Spieler:in muss einen Namen haben.'**
String get no_name_message; String get no_name_message;
/// No description provided for @select_game_mode. /// No description provided for @select_game_mode.
@@ -374,6 +374,24 @@ abstract class AppLocalizations {
/// **'Spielmodus auswählen'** /// **'Spielmodus auswählen'**
String get select_game_mode; String get select_game_mode;
/// No description provided for @no_mode_selected.
///
/// In de, this message translates to:
/// **'Wähle einen Spielmodus'**
String get no_mode_selected;
/// No description provided for @no_default_mode.
///
/// In de, this message translates to:
/// **'Kein Modus'**
String get no_default_mode;
/// No description provided for @no_default_description.
///
/// In de, this message translates to:
/// **'Entscheide bei jedem Spiel selber, welchen Modus du spielen möchtest.'**
String get no_default_description;
/// No description provided for @point_limit_description. /// No description provided for @point_limit_description.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
@@ -404,6 +422,12 @@ abstract class AppLocalizations {
/// **'Kamikaze'** /// **'Kamikaze'**
String get kamikaze; String get kamikaze;
/// No description provided for @who_has_kamikaze.
///
/// In de, this message translates to:
/// **'Wer hat Kamikaze?'**
String get who_has_kamikaze;
/// No description provided for @done. /// No description provided for @done.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
@@ -429,6 +453,18 @@ abstract class AppLocalizations {
String bonus_points_message( String bonus_points_message(
int playerCount, String names, int pointLimit, int bonusPoints); int playerCount, String names, int pointLimit, int bonusPoints);
/// No description provided for @end_of_game_title.
///
/// In de, this message translates to:
/// **'Spiel beendet'**
String get end_of_game_title;
/// No description provided for @end_of_game_message.
///
/// In de, this message translates to:
/// **'{playerCount, plural, =1{{names} hat das Spiel mit {points} Punkten gewonnen. Glückwunsch!} other{{names} haben das Spiel mit {points} Punkten gewonnen. Glückwunsch!}}'**
String end_of_game_message(int playerCount, String names, int points);
/// No description provided for @end_game. /// No description provided for @end_game.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
@@ -477,11 +513,23 @@ abstract class AppLocalizations {
/// **'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.'** /// **'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.'**
String get end_game_message; String get end_game_message;
/// No description provided for @game_process. /// No description provided for @statistics.
///
/// In de, this message translates to:
/// **'Statistiken'**
String get statistics;
/// No description provided for @point_overview.
///
/// In de, this message translates to:
/// **'Punkteübersicht'**
String get point_overview;
/// No description provided for @scoring_history.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
/// **'Spielverlauf'** /// **'Spielverlauf'**
String get game_process; String get scoring_history;
/// No description provided for @empty_graph_text. /// No description provided for @empty_graph_text.
/// ///
@@ -501,23 +549,17 @@ abstract class AppLocalizations {
/// **'Cabo-Strafe'** /// **'Cabo-Strafe'**
String get cabo_penalty; String get cabo_penalty;
/// No description provided for @cabo_penalty_subtitle.
///
/// In de, this message translates to:
/// **'... für falsches Cabo sagen'**
String get cabo_penalty_subtitle;
/// No description provided for @point_limit. /// No description provided for @point_limit.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
/// **'Punkte-Limit'** /// **'Punkte-Limit'**
String get point_limit; String get point_limit;
/// No description provided for @point_limit_subtitle. /// No description provided for @standard_mode.
/// ///
/// In de, this message translates to: /// In de, this message translates to:
/// **'... hier ist Schluss'** /// **'Standard-Modus'**
String get point_limit_subtitle; String get standard_mode;
/// No description provided for @reset_to_default. /// No description provided for @reset_to_default.
/// ///

View File

@@ -149,11 +149,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get no_name_title => 'Kein Name'; String get no_name_title => 'Kein Name';
@override @override
String get no_name_message => 'Jeder Spieler muss einen Namen haben.'; String get no_name_message => 'Jede:r Spieler:in muss einen Namen haben.';
@override @override
String get select_game_mode => 'Spielmodus auswählen'; String get select_game_mode => 'Spielmodus auswählen';
@override
String get no_mode_selected => 'Wähle einen Spielmodus';
@override
String get no_default_mode => 'Kein Modus';
@override
String get no_default_description =>
'Entscheide bei jedem Spiel selber, welchen Modus du spielen möchtest.';
@override @override
String point_limit_description(int pointLimit) { String point_limit_description(int pointLimit) {
return 'Es wird so lange gespielt, bis ein:e Spieler:in mehr als $pointLimit Punkte erreicht'; return 'Es wird so lange gespielt, bis ein:e Spieler:in mehr als $pointLimit Punkte erreicht';
@@ -172,6 +182,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get kamikaze => 'Kamikaze'; String get kamikaze => 'Kamikaze';
@override
String get who_has_kamikaze => 'Wer hat Kamikaze?';
@override @override
String get done => 'Fertig'; String get done => 'Fertig';
@@ -195,6 +208,21 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get end_of_game_title => 'Spiel beendet';
@override
String end_of_game_message(int playerCount, String names, int points) {
String _temp0 = intl.Intl.pluralLogic(
playerCount,
locale: localeName,
other:
'$names haben das Spiel mit $points Punkten gewonnen. Glückwunsch!',
one: '$names hat das Spiel mit $points Punkten gewonnen. Glückwunsch!',
);
return '$_temp0';
}
@override @override
String get end_game => 'Spiel beenden'; String get end_game => 'Spiel beenden';
@@ -222,7 +250,13 @@ class AppLocalizationsDe extends AppLocalizations {
'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.'; 'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.';
@override @override
String get game_process => 'Spielverlauf'; String get statistics => 'Statistiken';
@override
String get point_overview => 'Punkteübersicht';
@override
String get scoring_history => 'Spielverlauf';
@override @override
String get empty_graph_text => String get empty_graph_text =>
@@ -234,14 +268,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cabo_penalty => 'Cabo-Strafe'; String get cabo_penalty => 'Cabo-Strafe';
@override
String get cabo_penalty_subtitle => '... für falsches Cabo sagen';
@override @override
String get point_limit => 'Punkte-Limit'; String get point_limit => 'Punkte-Limit';
@override @override
String get point_limit_subtitle => '... hier ist Schluss'; String get standard_mode => 'Standard-Modus';
@override @override
String get reset_to_default => 'Auf Standard zurücksetzen'; String get reset_to_default => 'Auf Standard zurücksetzen';

View File

@@ -61,7 +61,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get empty_text_2 => String get empty_text_2 =>
'Add a new round using the button in the top right corner.'; 'Create a new game using the button in the top right.';
@override @override
String get delete_game_title => 'Delete game?'; String get delete_game_title => 'Delete game?';
@@ -119,31 +119,32 @@ class AppLocalizationsEn extends AppLocalizations {
String get create_game => 'Create Game'; String get create_game => 'Create Game';
@override @override
String get max_players_title => 'Maximum reached'; String get max_players_title => 'Player Limit Reached';
@override @override
String get max_players_message => 'A maximum of 5 players can be added.'; String get max_players_message => 'You can add a maximum of 5 players.';
@override @override
String get no_gameTitle_title => 'No Title'; String get no_gameTitle_title => 'Missing Game Title';
@override @override
String get no_gameTitle_message => 'You must enter a title for the game.'; String get no_gameTitle_message => 'Please enter a title for your game.';
@override @override
String get no_mode_title => 'No Mode'; String get no_mode_title => 'Game Mode Required';
@override @override
String get no_mode_message => 'You must select a game mode.'; String get no_mode_message => 'Please select a game mode to continue';
@override @override
String get min_players_title => 'Too few players'; String get min_players_title => 'Too Few Players';
@override @override
String get min_players_message => 'At least 2 players must be added.'; String get min_players_message =>
'At least 2 players are required to start the game.';
@override @override
String get no_name_title => 'No Name'; String get no_name_title => 'Missing Player Names';
@override @override
String get no_name_message => 'Each player must have a name.'; String get no_name_message => 'Each player must have a name.';
@@ -151,24 +152,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get select_game_mode => 'Select game mode'; String get select_game_mode => 'Select game mode';
@override
String get no_mode_selected => 'No mode selected';
@override
String get no_default_mode => 'No default mode';
@override
String get no_default_description => 'The default mode gets reset.';
@override @override
String point_limit_description(int pointLimit) { String point_limit_description(int pointLimit) {
return 'The game ends when a player reaches more than $pointLimit points.'; return 'The game ends when a player scores more than $pointLimit points.';
} }
@override @override
String get unlimited_description => String get unlimited_description =>
'There is no limit. The game continues until you decide to stop.'; 'The game continues until you decide to stop playing';
@override @override
String get results => 'Results'; String get results => 'Results';
@override @override
String get who_said_cabo => 'Who said CABO?'; String get who_said_cabo => 'Who called Cabo?';
@override @override
String get kamikaze => 'Kamikaze'; String get kamikaze => 'Kamikaze';
@override
String get who_has_kamikaze => 'Who has Kamikaze?';
@override @override
String get done => 'Done'; String get done => 'Done';
@@ -192,6 +205,21 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get end_of_game_title => 'End of Game';
@override
String end_of_game_message(int playerCount, String names, int points) {
String _temp0 = intl.Intl.pluralLogic(
playerCount,
locale: localeName,
other:
'$names won the game with $points points. Congratulations to everyone!',
one: '$names won the game with $points points. Congratulations!',
);
return '$_temp0';
}
@override @override
String get end_game => 'End Game'; String get end_game => 'End Game';
@@ -219,7 +247,13 @@ class AppLocalizationsEn extends AppLocalizations {
'Do you want to end the game? The game gets marked as finished and cannot be continued.'; 'Do you want to end the game? The game gets marked as finished and cannot be continued.';
@override @override
String get game_process => 'Scoring History'; String get statistics => 'Statistics';
@override
String get point_overview => 'Point Overview';
@override
String get scoring_history => 'Scoring History';
@override @override
String get empty_graph_text => String get empty_graph_text =>
@@ -231,14 +265,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cabo_penalty => 'Cabo Penalty'; String get cabo_penalty => 'Cabo Penalty';
@override
String get cabo_penalty_subtitle => '... for falsely calling Cabo.';
@override @override
String get point_limit => 'Point Limit'; String get point_limit => 'Point Limit';
@override @override
String get point_limit_subtitle => '... the game ends here.'; String get standard_mode => 'Default Mode';
@override @override
String get reset_to_default => 'Reset to Default'; String get reset_to_default => 'Reset to Default';

View File

@@ -12,8 +12,6 @@ Future<void> main() async {
await SystemChrome.setPreferredOrientations( await SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
await ConfigService.initConfig(); await ConfigService.initConfig();
ConfigService.pointLimit = await ConfigService.getPointLimit();
ConfigService.caboPenalty = await ConfigService.getCaboPenalty();
await VersionService.init(); await VersionService.init();
runApp(const App()); runApp(const App());
} }

View File

@@ -1,11 +1,16 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_manager.dart'; import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/create_game_view.dart'; import 'package:cabo_counter/presentation/views/create_game_view.dart';
import 'package:cabo_counter/presentation/views/graph_view.dart'; import 'package:cabo_counter/presentation/views/graph_view.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/presentation/views/points_view.dart';
import 'package:cabo_counter/presentation/views/round_view.dart'; import 'package:cabo_counter/presentation/views/round_view.dart';
import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:collection/collection.dart';
import 'package:confetti/confetti.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -19,6 +24,9 @@ class ActiveGameView extends StatefulWidget {
} }
class _ActiveGameViewState extends State<ActiveGameView> { class _ActiveGameViewState extends State<ActiveGameView> {
final confettiController = ConfettiController(
duration: const Duration(seconds: 10),
);
late final GameSession gameSession; late final GameSession gameSession;
late List<int> denseRanks; late List<int> denseRanks;
late List<int> sortedPlayerIndices; late List<int> sortedPlayerIndices;
@@ -31,200 +39,257 @@ class _ActiveGameViewState extends State<ActiveGameView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return Stack(
listenable: gameSession, children: [
builder: (context, _) { ListenableBuilder(
sortedPlayerIndices = _getSortedPlayerIndices(); listenable: gameSession,
denseRanks = _calculateDenseRank( builder: (context, _) {
gameSession.playerScores, sortedPlayerIndices); sortedPlayerIndices = _getSortedPlayerIndices();
return CupertinoPageScaffold( denseRanks = _calculateDenseRank(
navigationBar: CupertinoNavigationBar( gameSession.playerScores, sortedPlayerIndices);
middle: Text(gameSession.gameTitle), return CupertinoPageScaffold(
), navigationBar: CupertinoNavigationBar(
child: SafeArea( middle: Text(
child: SingleChildScrollView( gameSession.gameTitle,
child: Column( overflow: TextOverflow.ellipsis,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ ),
Padding( child: SafeArea(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), child: SingleChildScrollView(
child: Text( child: Column(
AppLocalizations.of(context).players, crossAxisAlignment: CrossAxisAlignment.start,
style: CustomTheme.rowTitle, children: [
), Padding(
), padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
ListView.builder( child: Text(
shrinkWrap: true, AppLocalizations.of(context).players,
physics: const NeverScrollableScrollPhysics(), style: CustomTheme.rowTitle,
itemCount: gameSession.players.length,
itemBuilder: (BuildContext context, int index) {
int playerIndex = sortedPlayerIndices[index];
return CupertinoListTile(
title: Row(
children: [
_getPlacementTextWidget(index),
const SizedBox(width: 5),
Text(
gameSession.players[playerIndex],
style: const TextStyle(
fontWeight: FontWeight.bold),
),
],
), ),
trailing: Row( ),
children: [ ListView.builder(
const SizedBox(width: 5), shrinkWrap: true,
Text('${gameSession.playerScores[playerIndex]} ' physics: const NeverScrollableScrollPhysics(),
'${AppLocalizations.of(context).points}') itemCount: gameSession.players.length,
], itemBuilder: (BuildContext context, int index) {
), int playerIndex = sortedPlayerIndices[index];
); return CupertinoListTile(
}, title: Row(
), children: [
Padding( _getPlacementTextWidget(index),
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), const SizedBox(width: 5),
child: Text( Text(
AppLocalizations.of(context).rounds, gameSession.players[playerIndex],
style: CustomTheme.rowTitle, style: const TextStyle(
), fontWeight: FontWeight.bold),
), ),
ListView.builder( ],
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: gameSession.roundNumber,
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: const EdgeInsets.all(1),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(
'${AppLocalizations.of(context).round} ${index + 1}',
), ),
trailing: trailing: Row(
index + 1 != gameSession.roundNumber || children: [
const SizedBox(width: 5),
Text(
'${gameSession.playerScores[playerIndex]} '
'${AppLocalizations.of(context).points}')
],
),
);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).rounds,
style: CustomTheme.rowTitle,
),
),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: gameSession.roundNumber,
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: const EdgeInsets.all(1),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(
'${AppLocalizations.of(context).round} ${index + 1}',
),
trailing: index + 1 !=
gameSession.roundNumber ||
gameSession.isGameFinished == true gameSession.isGameFinished == true
? (const Text('\u{2705}', ? (const Text('\u{2705}',
style: TextStyle(fontSize: 22))) style: TextStyle(fontSize: 22)))
: const Text('\u{23F3}', : const Text('\u{23F3}',
style: TextStyle(fontSize: 22)), style: TextStyle(fontSize: 22)),
onTap: () async { onTap: () async {
_openRoundView(index + 1); _openRoundView(context, index + 1);
}, },
)); ));
}, },
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text( child: Text(
AppLocalizations.of(context).game, AppLocalizations.of(context).statistics,
style: CustomTheme.rowTitle, style: CustomTheme.rowTitle,
), ),
), ),
Column( Column(
children: [ children: [
CupertinoListTile( CupertinoListTile(
title: Text( title: Text(
AppLocalizations.of(context).game_process, AppLocalizations.of(context)
.scoring_history,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => GraphView(
gameSession: gameSession,
)))),
CupertinoListTile(
title: Text(
AppLocalizations.of(context).point_overview,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => PointsView(
gameSession: gameSession,
)))),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
Column(
children: [
Visibility(
visible: !gameSession.isPointsLimitEnabled,
child: CupertinoListTile(
title: Text(
AppLocalizations.of(context).end_game,
style: gameSession.roundNumber > 1 &&
!gameSession.isGameFinished
? const TextStyle(color: Colors.white)
: const TextStyle(
color: Colors.white30),
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () {
if (gameSession.roundNumber > 1 &&
!gameSession.isGameFinished) {
_showEndGameDialog();
}
}),
), ),
backgroundColorActivated: CupertinoListTile(
CustomTheme.backgroundColor,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => GraphView(
gameSession: gameSession,
)))),
Visibility(
visible: !gameSession.isPointsLimitEnabled,
child: CupertinoListTile(
title: Text( title: Text(
AppLocalizations.of(context).end_game, AppLocalizations.of(context).delete_game,
style: gameSession.roundNumber > 1 &&
!gameSession.isGameFinished
? const TextStyle(color: Colors.white)
: const TextStyle(color: Colors.white30),
), ),
backgroundColorActivated: backgroundColorActivated:
CustomTheme.backgroundColor, CustomTheme.backgroundColor,
onTap: () { onTap: () {
if (gameSession.roundNumber > 1 && _showDeleteGameDialog().then((value) {
!gameSession.isGameFinished) { if (value) {
_showEndGameDialog(); _removeGameSession(gameSession);
} }
}), });
), },
CupertinoListTile(
title: Text(
AppLocalizations.of(context).delete_game,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () {
_showDeleteGameDialog().then((value) {
if (value) {
_removeGameSession(gameSession);
}
});
},
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context)
.new_game_same_settings,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () {
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (_) => CreateGameView(
gameTitle: gameSession.gameTitle,
isPointsLimitEnabled: widget
.gameSession
.isPointsLimitEnabled,
players: gameSession.players,
)));
},
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context).export_game,
), ),
backgroundColorActivated: CupertinoListTile(
CustomTheme.backgroundColor, title: Text(
onTap: () async { AppLocalizations.of(context)
final success = await LocalStorageService .new_game_same_settings,
.exportSingleGameSession( ),
widget.gameSession); backgroundColorActivated:
if (!success && context.mounted) { CustomTheme.backgroundColor,
showCupertinoDialog( onTap: () {
context: context, Navigator.pushReplacement(
builder: (context) => CupertinoAlertDialog( context,
title: Text(AppLocalizations.of(context) CupertinoPageRoute(
.export_error_title), builder: (_) => CreateGameView(
content: Text(AppLocalizations.of(context) gameTitle:
.export_error_message), gameSession.gameTitle,
actions: [ gameMode: widget.gameSession
CupertinoDialogAction( .isPointsLimitEnabled ==
child: Text( true
AppLocalizations.of(context).ok), ? GameMode.pointLimit
onPressed: () => : GameMode.unlimited,
Navigator.pop(context), players: gameSession.players,
)));
},
),
CupertinoListTile(
title: Text(
AppLocalizations.of(context).export_game,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () async {
final success = await LocalStorageService
.exportSingleGameSession(
widget.gameSession);
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) =>
CupertinoAlertDialog(
title: Text(
AppLocalizations.of(context)
.export_error_title),
content: Text(
AppLocalizations.of(context)
.export_error_message),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context)
.ok),
onPressed: () =>
Navigator.pop(context),
),
],
), ),
], );
), }
); }),
} ],
}), )
], ],
) ),
], ),
), ));
), }),
)); Column(
}); mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: ConfettiWidget(
blastDirectionality: BlastDirectionality.explosive,
particleDrag: 0.07,
emissionFrequency: 0.1,
numberOfParticles: 10,
minBlastForce: 5,
maxBlastForce: 20,
confettiController: confettiController,
),
),
],
),
],
);
} }
/// Shows a dialog to confirm ending the game. /// Shows a dialog to confirm ending the game.
@@ -247,6 +312,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
onPressed: () { onPressed: () {
setState(() { setState(() {
gameManager.endGame(gameSession.id); gameManager.endGame(gameSession.id);
_playFinishAnimation(context);
}); });
Navigator.pop(context); Navigator.pop(context);
}, },
@@ -376,8 +442,8 @@ class _ActiveGameViewState extends State<ActiveGameView> {
/// Recursively opens the RoundView for the specified round number. /// Recursively opens the RoundView for the specified round number.
/// It starts with the given [roundNumber] and continues to open the next round /// It starts with the given [roundNumber] and continues to open the next round
/// until the user navigates back or the round number is invalid. /// until the user navigates back or the round number is invalid.
void _openRoundView(int roundNumber) async { void _openRoundView(BuildContext context, int roundNumber) async {
final val = await Navigator.of(context, rootNavigator: true).push( final round = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute( CupertinoPageRoute(
fullscreenDialog: true, fullscreenDialog: true,
builder: (context) => RoundView( builder: (context) => RoundView(
@@ -386,11 +452,58 @@ class _ActiveGameViewState extends State<ActiveGameView> {
), ),
), ),
); );
if (val != null && val >= 0) {
if (widget.gameSession.isGameFinished && context.mounted) {
_playFinishAnimation(context);
}
// If the previous round was not the last one
if (round != null && round >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 600)); await Future.delayed(
_openRoundView(val); const Duration(milliseconds: Constants.roundViewDelay));
if (context.mounted) {
_openRoundView(context, round);
}
}); });
} }
} }
/// Plays the confetti animation and shows a dialog with the winner's information.
Future<void> _playFinishAnimation(BuildContext context) async {
String winner = widget.gameSession.winner;
int winnerPoints = widget.gameSession.playerScores.min;
int winnerAmount = winner.contains('&') ? 2 : 1;
confettiController.play();
await Future.delayed(const Duration(milliseconds: Constants.popUpDelay));
if (context.mounted) {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).end_of_game_title),
content: Text(AppLocalizations.of(context)
.end_of_game_message(winnerAmount, winner, winnerPoints)),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () {
confettiController.stop();
Navigator.pop(context);
},
),
],
);
});
}
}
@override
void dispose() {
confettiController.dispose();
super.dispose();
}
} }

View File

@@ -1,11 +1,16 @@
import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_manager.dart'; import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/active_game_view.dart'; import 'package:cabo_counter/presentation/views/active_game_view.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart'; import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/config_service.dart'; import 'package:cabo_counter/services/config_service.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:uuid/uuid.dart';
enum CreateStatus { enum CreateStatus {
noGameTitle, noGameTitle,
@@ -16,15 +21,15 @@ enum CreateStatus {
} }
class CreateGameView extends StatefulWidget { class CreateGameView extends StatefulWidget {
final GameMode gameMode;
final String? gameTitle; final String? gameTitle;
final bool? isPointsLimitEnabled;
final List<String>? players; final List<String>? players;
const CreateGameView({ const CreateGameView({
super.key, super.key,
this.gameTitle, this.gameTitle,
this.isPointsLimitEnabled,
this.players, this.players,
required this.gameMode,
}); });
@override @override
@@ -33,29 +38,39 @@ class CreateGameView extends StatefulWidget {
} }
class _CreateGameViewState extends State<CreateGameView> { class _CreateGameViewState extends State<CreateGameView> {
final TextEditingController _gameTitleTextController =
TextEditingController();
/// List of text controllers for player names.
final List<TextEditingController> _playerNameTextControllers = [ final List<TextEditingController> _playerNameTextControllers = [
TextEditingController() TextEditingController()
]; ];
final TextEditingController _gameTitleTextController =
TextEditingController(); /// List of focus nodes for player name text fields.
final List<FocusNode> _playerNameFocusNodes = [FocusNode()];
/// Maximum number of players allowed in the game. /// Maximum number of players allowed in the game.
final int maxPlayers = 5; final int maxPlayers = 5;
/// Variable to store whether the points limit feature is enabled. /// Factor to adjust the view length when the keyboard is visible.
bool? _isPointsLimitEnabled; final double keyboardHeightAdjustmentFactor = 0.75;
/// Variable to hold the selected game mode.
late GameMode gameMode;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isPointsLimitEnabled = widget.isPointsLimitEnabled; gameMode = widget.gameMode;
_gameTitleTextController.text = widget.gameTitle ?? ''; _gameTitleTextController.text = widget.gameTitle ?? '';
if (widget.players != null) { if (widget.players != null) {
_playerNameTextControllers.clear(); _playerNameTextControllers.clear();
for (var player in widget.players!) { for (var player in widget.players!) {
_playerNameTextControllers.add(TextEditingController(text: player)); _playerNameTextControllers.add(TextEditingController(text: player));
_playerNameFocusNodes.add(FocusNode());
} }
} }
} }
@@ -63,124 +78,100 @@ class _CreateGameViewState extends State<CreateGameView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CupertinoPageScaffold( return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
previousPageTitle: AppLocalizations.of(context).overview, previousPageTitle: AppLocalizations.of(context).overview,
middle: Text(AppLocalizations.of(context).new_game), middle: Text(AppLocalizations.of(context).new_game),
), ),
child: SafeArea( child: SafeArea(
child: Center( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text( child: Text(
AppLocalizations.of(context).game, AppLocalizations.of(context).game,
style: CustomTheme.rowTitle, style: CustomTheme.rowTitle,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
maxLength: 16,
prefix: Text(AppLocalizations.of(context).name),
textAlign: TextAlign.right,
placeholder: AppLocalizations.of(context).game_title,
controller: _gameTitleTextController,
),
),
// Spielmodus-Auswahl mit Chevron
Padding(
padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: CupertinoTextField(
decoration: const BoxDecoration(),
readOnly: true,
prefix: Text(AppLocalizations.of(context).mode),
suffix: Row(
children: [
Text(
_isPointsLimitEnabled == null
? AppLocalizations.of(context).select_mode
: (_isPointsLimitEnabled!
? '${ConfigService.pointLimit} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 3),
const CupertinoListTileChevron(),
],
), ),
onTap: () async {
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: ConfigService.pointLimit,
),
),
);
if (selectedMode != null) {
setState(() {
_isPointsLimitEnabled = selectedMode;
});
}
},
), ),
), Padding(
Padding( padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0), child: CupertinoTextField(
child: Text( decoration: const BoxDecoration(),
AppLocalizations.of(context).players, maxLength: 20,
style: CustomTheme.rowTitle, prefix: Text(AppLocalizations.of(context).name),
textAlign: TextAlign.right,
placeholder: AppLocalizations.of(context).game_title,
controller: _gameTitleTextController,
onSubmitted: (_) {
_playerNameFocusNodes.isNotEmpty
? _playerNameFocusNodes[0].requestFocus()
: FocusScope.of(context).unfocus();
},
textInputAction: _playerNameFocusNodes.isNotEmpty
? TextInputAction.next
: TextInputAction.done,
),
), ),
), Padding(
Expanded( padding: const EdgeInsets.fromLTRB(15, 10, 10, 0),
child: ListView.builder( child: CupertinoTextField(
itemCount: _playerNameTextControllers.length + decoration: const BoxDecoration(),
1, // +1 für den + Button readOnly: true,
itemBuilder: (context, index) { prefix: Text(AppLocalizations.of(context).mode),
if (index == _playerNameTextControllers.length) { suffix: Row(
// + Button als letztes Element children: [
return Padding( _getDisplayedGameMode(),
padding: const EdgeInsets.symmetric(vertical: 8.0), const SizedBox(width: 3),
child: CupertinoButton( const CupertinoListTileChevron(),
padding: EdgeInsets.zero, ],
child: Row( ),
mainAxisAlignment: MainAxisAlignment.center, onTap: () async {
children: [ final selectedMode = await Navigator.push(
const Icon( context,
CupertinoIcons.add_circled, CupertinoPageRoute(
color: CupertinoColors.activeGreen, builder: (context) => ModeSelectionMenu(
size: 25, pointLimit: ConfigService.getPointLimit(),
), showDeselection: false,
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).add_player,
style: const TextStyle(
color: CupertinoColors.activeGreen,
),
),
],
), ),
onPressed: () {
if (_playerNameTextControllers.length < maxPlayers) {
setState(() {
_playerNameTextControllers
.add(TextEditingController());
});
} else {
showFeedbackDialog(CreateStatus.maxPlayers);
}
},
), ),
); );
} else {
// Spieler-Einträge setState(() {
gameMode = selectedMode ?? gameMode;
});
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).players,
style: CustomTheme.rowTitle,
),
),
ReorderableListView.builder(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(8),
itemCount: _playerNameTextControllers.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (oldIndex < _playerNameTextControllers.length &&
newIndex <= _playerNameTextControllers.length) {
if (newIndex > oldIndex) newIndex--;
final item =
_playerNameTextControllers.removeAt(oldIndex);
_playerNameTextControllers.insert(newIndex, item);
}
});
},
itemBuilder: (context, index) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric( key: ValueKey(index),
vertical: 8.0, horizontal: 5), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row( child: Row(
children: [ children: [
CupertinoButton( CupertinoButton(
@@ -200,82 +191,187 @@ class _CreateGameViewState extends State<CreateGameView> {
Expanded( Expanded(
child: CupertinoTextField( child: CupertinoTextField(
controller: _playerNameTextControllers[index], controller: _playerNameTextControllers[index],
focusNode: _playerNameFocusNodes[index],
maxLength: 12, maxLength: 12,
placeholder: placeholder:
'${AppLocalizations.of(context).player} ${index + 1}', '${AppLocalizations.of(context).player} ${index + 1}',
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
textInputAction:
index + 1 < _playerNameTextControllers.length
? TextInputAction.next
: TextInputAction.done,
onSubmitted: (_) {
if (index + 1 < _playerNameFocusNodes.length) {
_playerNameFocusNodes[index + 1]
.requestFocus();
} else {
FocusScope.of(context).unfocus();
}
},
), ),
), ),
AnimatedOpacity(
opacity: _playerNameTextControllers.length > 1
? 1.0
: 0.0,
duration: const Duration(
milliseconds: Constants.fadeInDuration),
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ReorderableDragStartListener(
index: index,
child: const Icon(
CupertinoIcons.line_horizontal_3,
color: CupertinoColors.systemGrey,
),
),
),
)
], ],
), ),
); );
} }),
}, Padding(
), padding: const EdgeInsets.fromLTRB(8, 0, 8, 50),
), child: Stack(
Center(
child: CupertinoButton(
padding: EdgeInsets.zero,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Row(
AppLocalizations.of(context).create_game, mainAxisAlignment: MainAxisAlignment.start,
style: const TextStyle( children: [
color: CupertinoColors.activeGreen, CupertinoButton(
padding: EdgeInsets.zero,
onPressed: null,
child: Icon(
CupertinoIcons.plus_circle_fill,
color: CustomTheme.primaryColor,
size: 25,
),
),
],
),
Center(
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: Center(
child: Text(
AppLocalizations.of(context).add_player,
style: TextStyle(
color: CustomTheme.primaryColor),
),
),
),
],
),
onPressed: () {
if (_playerNameTextControllers.length < maxPlayers) {
setState(() {
_playerNameTextControllers
.add(TextEditingController());
_playerNameFocusNodes.add(FocusNode());
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_playerNameFocusNodes.last.requestFocus();
});
} else {
_showFeedbackDialog(CreateStatus.maxPlayers);
}
},
), ),
), ),
], ],
), ),
onPressed: () async {
if (_gameTitleTextController.text == '') {
showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (_isPointsLimitEnabled == null) {
showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!everyPlayerHasAName()) {
showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
List<String> players = [];
for (var controller in _playerNameTextControllers) {
players.add(controller.text);
}
GameSession gameSession = GameSession(
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: ConfigService.pointLimit,
caboPenalty: ConfigService.caboPenalty,
isPointsLimitEnabled: _isPointsLimitEnabled!,
);
final index = await gameManager.addGameSession(gameSession);
final session = gameManager.gameList[index];
if (context.mounted) {
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(gameSession: session)));
}
},
), ),
), Padding(
], padding: const EdgeInsets.fromLTRB(0, 0, 0, 50),
)))); child: Center(
key: const ValueKey('create_game_button'),
child: CustomButton(
child: Text(
AppLocalizations.of(context).create_game,
style: TextStyle(
color: CustomTheme.primaryColor,
),
),
onPressed: () {
_checkAllGameAttributes();
},
),
),
),
KeyboardVisibilityBuilder(builder: (context, visible) {
if (visible) {
return SizedBox(
height: MediaQuery.of(context).viewInsets.bottom *
keyboardHeightAdjustmentFactor,
);
} else {
return const SizedBox.shrink();
}
})
],
),
)));
}
/// Returns a widget that displays the currently selected game mode in the View.
Text _getDisplayedGameMode() {
if (gameMode == GameMode.none) {
return Text(AppLocalizations.of(context).no_mode_selected);
} else if (gameMode == GameMode.pointLimit) {
return Text(
'${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}',
style: TextStyle(color: CustomTheme.primaryColor));
} else {
return Text(AppLocalizations.of(context).unlimited,
style: TextStyle(color: CustomTheme.primaryColor));
}
}
/// Checks all game attributes before creating a new game.
/// If any attribute is invalid, it shows a feedback dialog.
/// If all attributes are valid, it calls the `_createGame` method.
void _checkAllGameAttributes() {
if (_gameTitleTextController.text == '') {
_showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (gameMode == GameMode.none) {
_showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
_showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!_everyPlayerHasAName()) {
_showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
_createGame();
}
/// Checks if every player has a name.
/// Returns true if all players have a name, false otherwise.
bool _everyPlayerHasAName() {
for (var controller in _playerNameTextControllers) {
if (controller.text == '') {
return false;
}
}
return true;
} }
/// Displays a feedback dialog based on the [CreateStatus]. /// Displays a feedback dialog based on the [CreateStatus].
void showFeedbackDialog(CreateStatus status) { void _showFeedbackDialog(CreateStatus status) {
final (title, message) = _getDialogContent(status); final (title, message) = _getDialogContent(status);
showCupertinoDialog( showCupertinoDialog(
@@ -326,15 +422,36 @@ class _CreateGameViewState extends State<CreateGameView> {
} }
} }
/// Checks if every player has a name. /// Creates a new gameSession and navigates to the active game view.
/// Returns true if all players have a name, false otherwise. /// This method creates a new gameSession object with the provided attributes in the text fields.
bool everyPlayerHasAName() { /// It then adds the game session to the game manager and navigates to the active game view.
void _createGame() {
var uuid = const Uuid();
final String id = uuid.v1();
List<String> players = [];
for (var controller in _playerNameTextControllers) { for (var controller in _playerNameTextControllers) {
if (controller.text == '') { players.add(controller.text);
return false;
}
} }
return true;
bool isPointsLimitEnabled = gameMode == GameMode.pointLimit;
GameSession gameSession = GameSession(
id: id,
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: ConfigService.getPointLimit(),
caboPenalty: ConfigService.getCaboPenalty(),
isPointsLimitEnabled: isPointsLimitEnabled,
);
gameManager.addGameSession(gameSession);
final session = gameManager.getGameSessionById(id) ?? gameSession;
Navigator.pushReplacement(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(gameSession: session)));
} }
@override @override
@@ -343,6 +460,9 @@ class _CreateGameViewState extends State<CreateGameView> {
for (var controller in _playerNameTextControllers) { for (var controller in _playerNameTextControllers) {
controller.dispose(); controller.dispose();
} }
for (var focusnode in _playerNameFocusNodes) {
focusnode.dispose();
}
super.dispose(); super.dispose();
} }

View File

@@ -27,46 +27,61 @@ class _GraphViewState extends State<GraphView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CupertinoPageScaffold( return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).game_process), middle: Text(AppLocalizations.of(context).scoring_history),
previousPageTitle: AppLocalizations.of(context).back, previousPageTitle: AppLocalizations.of(context).back,
), ),
child: widget.gameSession.roundNumber > 1 child: Visibility(
? Padding( visible: widget.gameSession.roundNumber > 1 ||
padding: const EdgeInsets.fromLTRB(0, 100, 0, 0), widget.gameSession.isGameFinished,
child: SfCartesianChart( replacement: Column(
legend: const Legend( mainAxisAlignment: MainAxisAlignment.center,
overflowMode: LegendItemOverflowMode.wrap, crossAxisAlignment: CrossAxisAlignment.center,
isVisible: true, children: [
position: LegendPosition.bottom), const Center(
primaryXAxis: const NumericAxis( child: Icon(CupertinoIcons.chart_bar_alt_fill, size: 60),
interval: 1, ),
decimalPlaces: 0, const SizedBox(height: 10),
), Padding(
primaryYAxis: const NumericAxis( padding: const EdgeInsets.symmetric(horizontal: 40),
interval: 1, child: Text(
decimalPlaces: 0, AppLocalizations.of(context).empty_graph_text,
), textAlign: TextAlign.center,
series: getCumulativeScores(), style: const TextStyle(fontSize: 16),
), ),
) ),
: Column( ],
mainAxisAlignment: MainAxisAlignment.center, ),
crossAxisAlignment: CrossAxisAlignment.center, child: Padding(
children: [ padding: const EdgeInsets.fromLTRB(0, 100, 0, 0),
const Center( child: SfCartesianChart(
child: Icon(CupertinoIcons.chart_bar_alt_fill, size: 60), enableAxisAnimation: true,
), legend: const Legend(
const SizedBox(height: 10), overflowMode: LegendItemOverflowMode.wrap,
Padding( isVisible: true,
padding: const EdgeInsets.symmetric(horizontal: 40), position: LegendPosition.bottom),
child: Text( primaryXAxis: const NumericAxis(
AppLocalizations.of(context).empty_graph_text, labelStyle: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, interval: 1,
style: const TextStyle(fontSize: 16), decimalPlaces: 0,
), ),
), primaryYAxis: NumericAxis(
], labelStyle: const TextStyle(fontWeight: FontWeight.bold),
)); labelAlignment: LabelAlignment.center,
labelPosition: ChartDataLabelPosition.inside,
interval: 1,
decimalPlaces: 0,
axisLabelFormatter: (AxisLabelRenderDetails details) {
if (details.value == 0) {
return ChartAxisLabel('', const TextStyle());
}
return ChartAxisLabel(
'${details.value.toInt()}', const TextStyle());
},
),
series: getCumulativeScores(),
),
),
));
} }
/// Returns a list of LineSeries representing the cumulative scores of each player. /// Returns a list of LineSeries representing the cumulative scores of each player.

View File

@@ -58,162 +58,167 @@ class _MainMenuViewState extends State<MainMenuView> {
listenable: gameManager, listenable: gameManager,
builder: (context, _) { builder: (context, _) {
return CupertinoPageScaffold( return CupertinoPageScaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const SettingsView(),
),
).then((_) {
setState(() {});
});
},
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: const Text('Cabo Counter'),
trailing: IconButton(
onPressed: () => Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (context) => const CreateGameView(), builder: (context) => const SettingsView(),
), ),
).then((_) {
setState(() {});
});
},
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: Text(AppLocalizations.of(context).app_name),
trailing: IconButton(
onPressed: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => CreateGameView(
gameMode: ConfigService.getGameMode()),
),
),
icon: const Icon(CupertinoIcons.add)),
),
child: CupertinoPageScaffold(
child: SafeArea(
child: Visibility(
visible: _isLoading,
replacement: Visibility(
visible: gameManager.gameList.isEmpty,
replacement: ListView.separated(
itemCount: gameManager.gameList.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 0.5,
color: CustomTheme.white.withAlpha(50),
indent: 50,
endIndent: 50,
), ),
icon: const Icon(CupertinoIcons.add)), itemBuilder: (context, index) {
), final session = gameManager.gameList[index];
child: CupertinoPageScaffold( return ListenableBuilder(
child: SafeArea( listenable: session,
child: _isLoading builder: (context, _) {
? const Center(child: CupertinoActivityIndicator()) return Dismissible(
: gameManager.gameList.isEmpty key: Key(session.id),
? Column( background: Container(
mainAxisAlignment: MainAxisAlignment.center, color: CupertinoColors.destructiveRed,
children: [ alignment: Alignment.centerRight,
const SizedBox(height: 30), padding: const EdgeInsets.only(right: 20.0),
Center( child: const Icon(
child: GestureDetector( CupertinoIcons.delete,
onTap: () => Navigator.push( color: CupertinoColors.white,
context,
CupertinoPageRoute(
builder: (context) =>
const CreateGameView(),
), ),
), ),
child: Icon( direction: DismissDirection.endToStart,
CupertinoIcons.plus, confirmDismiss: (direction) async {
size: 60, return await _showDeleteGamePopup(
color: CustomTheme.primaryColor, context, session.gameTitle);
), },
)), onDismissed: (direction) {
const SizedBox(height: 10), gameManager.removeGameSessionById(session.id);
Padding( },
padding: dismissThresholds: const {
const EdgeInsets.symmetric(horizontal: 70), DismissDirection.startToEnd: 0.6
child: Text( },
'${AppLocalizations.of(context).empty_text_1}\n${AppLocalizations.of(context).empty_text_2}', child: Padding(
textAlign: TextAlign.center, padding: const EdgeInsets.symmetric(
style: const TextStyle(fontSize: 16), vertical: 10.0),
), child: CupertinoListTile(
), backgroundColorActivated:
], CustomTheme.backgroundColor,
) title: Text(session.gameTitle),
: ListView.builder( subtitle: Visibility(
itemCount: gameManager.gameList.length, visible: session.isGameFinished,
itemBuilder: (context, index) { replacement: Text(
final session = gameManager.gameList[index]; '${AppLocalizations.of(context).mode}: ${_translateGameMode(session.isPointsLimitEnabled)}',
return ListenableBuilder( style: const TextStyle(fontSize: 14),
listenable: session,
builder: (context, _) {
return Dismissible(
key: Key(session.gameTitle),
background: Container(
color: CupertinoColors.destructiveRed,
alignment: Alignment.centerRight,
padding:
const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
), ),
), child: Text(
direction: DismissDirection.endToStart, '\u{1F947} ${session.winner}',
confirmDismiss: (direction) async { style: const TextStyle(fontSize: 14),
final String gameTitle = gameManager )),
.gameList[index].gameTitle; trailing: Row(
return await _showDeleteGamePopup( children: [
context, gameTitle); const SizedBox(
}, width: 5,
onDismissed: (direction) {
gameManager
.removeGameSessionByIndex(index);
},
dismissThresholds: const {
DismissDirection.startToEnd: 0.6
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10.0),
child: CupertinoListTile(
backgroundColorActivated:
CustomTheme.backgroundColor,
title: Text(session.gameTitle),
subtitle:
session.isGameFinished == true
? Text(
'\u{1F947} ${session.winner}',
style: const TextStyle(
fontSize: 14),
)
: Text(
'${AppLocalizations.of(context).mode}: ${_translateGameMode(session.isPointsLimitEnabled)}',
style: const TextStyle(
fontSize: 14),
),
trailing: Row(
children: [
Text('${session.roundNumber}'),
const SizedBox(width: 3),
const Icon(CupertinoIcons
.arrow_2_circlepath_circle_fill),
const SizedBox(width: 15),
Text('${session.players.length}'),
const SizedBox(width: 3),
const Icon(
CupertinoIcons.person_2_fill),
],
),
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(
gameSession: session),
),
).then((_) {
setState(() {});
});
},
), ),
), Text('${session.roundNumber}'),
); const SizedBox(width: 3),
}); const Icon(CupertinoIcons
}, .arrow_2_circlepath_circle_fill),
const SizedBox(width: 15),
Text('${session.players.length}'),
const SizedBox(width: 3),
const Icon(
CupertinoIcons.person_2_fill),
],
),
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ActiveGameView(
gameSession: session),
),
).then((_) {
setState(() {});
});
},
),
),
);
});
},
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 30),
Center(
child: GestureDetector(
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => CreateGameView(
gameMode: ConfigService.getGameMode()),
),
), ),
), child: Icon(
), CupertinoIcons.plus,
); size: 60,
color: CustomTheme.primaryColor,
),
)),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 70),
child: Text(
'${AppLocalizations.of(context).empty_text_1}\n${AppLocalizations.of(context).empty_text_2}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
),
],
),
),
child: const Center(child: CupertinoActivityIndicator()),
),
)));
}); });
} }
/// Translates the game mode boolean into the corresponding String. /// Translates the game mode boolean into the corresponding String.
/// If [pointLimit] is true, it returns '101 Punkte', otherwise it returns 'Unbegrenzt'. /// If [pointLimit] is true, it returns '101 Punkte', otherwise it returns 'Unbegrenzt'.
String _translateGameMode(bool pointLimit) { String _translateGameMode(bool isPointLimitEnabled) {
if (pointLimit) { if (isPointLimitEnabled) {
return '${ConfigService.pointLimit} ${AppLocalizations.of(context).points}'; return '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}';
} }
return AppLocalizations.of(context).unlimited; return AppLocalizations.of(context).unlimited;
} }
@@ -237,7 +242,7 @@ class _MainMenuViewState extends State<MainMenuView> {
BadRatingDialogDecision badRatingDecision = BadRatingDialogDecision.cancel; BadRatingDialogDecision badRatingDecision = BadRatingDialogDecision.cancel;
// so that the bad rating dialog is not shown immediately // so that the bad rating dialog is not shown immediately
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: Constants.popUpDelay));
switch (preRatingDecision) { switch (preRatingDecision) {
case PreRatingDialogDecision.yes: case PreRatingDialogDecision.yes:

View File

@@ -2,9 +2,17 @@ import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
enum GameMode {
none,
pointLimit,
unlimited,
}
class ModeSelectionMenu extends StatelessWidget { class ModeSelectionMenu extends StatelessWidget {
final int pointLimit; final int pointLimit;
const ModeSelectionMenu({super.key, required this.pointLimit}); final bool showDeselection;
const ModeSelectionMenu(
{super.key, required this.pointLimit, required this.showDeselection});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -26,12 +34,12 @@ class ModeSelectionMenu extends StatelessWidget {
maxLines: 3, maxLines: 3,
), ),
onTap: () { onTap: () {
Navigator.pop(context, true); Navigator.pop(context, GameMode.pointLimit);
}, },
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: CupertinoListTile( child: CupertinoListTile(
title: Text(AppLocalizations.of(context).unlimited, title: Text(AppLocalizations.of(context).unlimited,
style: CustomTheme.modeTitle), style: CustomTheme.modeTitle),
@@ -41,10 +49,27 @@ class ModeSelectionMenu extends StatelessWidget {
maxLines: 3, maxLines: 3,
), ),
onTap: () { onTap: () {
Navigator.pop(context, false); Navigator.pop(context, GameMode.unlimited);
}, },
), ),
), ),
Visibility(
visible: showDeselection,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: CupertinoListTile(
title: Text(AppLocalizations.of(context).no_default_mode,
style: CustomTheme.modeTitle),
subtitle: Text(
AppLocalizations.of(context).no_default_description,
style: CustomTheme.modeDescription,
maxLines: 3,
),
onTap: () {
Navigator.pop(context, GameMode.none);
},
),
)),
], ],
), ),
); );

View File

@@ -0,0 +1,141 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class PointsView extends StatefulWidget {
final GameSession gameSession;
const PointsView({super.key, required this.gameSession});
@override
State<PointsView> createState() => _PointsViewState();
}
class _PointsViewState extends State<PointsView> {
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).point_overview),
previousPageTitle: AppLocalizations.of(context).back,
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(0, 100, 0, 0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: DataTable(
dataRowMinHeight: 60,
dataRowMaxHeight: 60,
dividerThickness: 0.5,
columnSpacing: 20,
columns: [
const DataColumn(
numeric: true,
headingRowAlignment: MainAxisAlignment.center,
label: Text(
'#',
style: TextStyle(fontWeight: FontWeight.bold),
),
columnWidth: IntrinsicColumnWidth(flex: 0.5)),
...widget.gameSession.players.map(
(player) => DataColumn(
label: FittedBox(
fit: BoxFit.fill,
child: Text(
player,
style: const TextStyle(fontWeight: FontWeight.bold),
)),
headingRowAlignment: MainAxisAlignment.center,
columnWidth: const IntrinsicColumnWidth(flex: 1)),
),
],
rows: [
...List<DataRow>.generate(
widget.gameSession.roundList.length,
(roundIndex) {
final round = widget.gameSession.roundList[roundIndex];
return DataRow(
cells: [
DataCell(Align(
alignment: Alignment.center,
child: Text(
'${roundIndex + 1}',
style: const TextStyle(fontSize: 20),
),
)),
...List.generate(widget.gameSession.players.length,
(playerIndex) {
final int score = round.scores[playerIndex];
final int update = round.scoreUpdates[playerIndex];
final bool saidCabo =
round.caboPlayerIndex == playerIndex;
return DataCell(
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: update <= 0
? CustomTheme.pointLossColor
: CustomTheme.pointGainColor,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${update >= 0 ? '+' : ''}$update',
style: const TextStyle(
color: CupertinoColors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Text('$score',
style: TextStyle(
fontWeight: saidCabo
? FontWeight.bold
: FontWeight.normal,
)),
],
),
),
);
}),
],
);
},
),
DataRow(
cells: [
const DataCell(Align(
alignment: Alignment.center,
child: Text(
'Σ',
style:
TextStyle(fontSize: 25, fontWeight: FontWeight.bold),
),
)),
...widget.gameSession.playerScores.map(
(score) => DataCell(
Center(
child: Text(
'$score',
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/widgets/custom_button.dart';
import 'package:cabo_counter/services/local_storage_service.dart'; import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -74,21 +75,22 @@ class _RoundViewState extends State<RoundView> {
return CupertinoPageScaffold( return CupertinoPageScaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
transitionBetweenRoutes: true, transitionBetweenRoutes: true,
leading: CupertinoButton( leading: CupertinoButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () => onPressed: () => {
{LocalStorageService.saveGameSessions(), Navigator.pop(context)}, LocalStorageService.saveGameSessions(),
child: Text(AppLocalizations.of(context).cancel), Navigator.pop(context)
), },
middle: Text(AppLocalizations.of(context).results), child: Text(AppLocalizations.of(context).cancel),
trailing: widget.gameSession.isGameFinished ),
? const Icon( middle: Text(AppLocalizations.of(context).results),
trailing: Visibility(
visible: widget.gameSession.isGameFinished,
child: const Icon(
CupertinoIcons.lock, CupertinoIcons.lock,
size: 25, size: 25,
) ))),
: null,
),
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
@@ -114,9 +116,10 @@ class _RoundViewState extends State<RoundView> {
vertical: 10, vertical: 10,
), ),
child: SizedBox( child: SizedBox(
height: 40, height: 60,
child: CupertinoSegmentedControl<int>( child: CupertinoSegmentedControl<int>(
unselectedColor: CustomTheme.backgroundTintColor, unselectedColor:
CustomTheme.mainElementBackgroundColor,
selectedColor: CustomTheme.primaryColor, selectedColor: CustomTheme.primaryColor,
groupValue: _caboPlayerIndex, groupValue: _caboPlayerIndex,
children: Map.fromEntries(widget.gameSession.players children: Map.fromEntries(widget.gameSession.players
@@ -130,7 +133,7 @@ class _RoundViewState extends State<RoundView> {
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
vertical: 6, vertical: 8,
), ),
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
@@ -154,27 +157,6 @@ class _RoundViewState extends State<RoundView> {
), ),
), ),
), ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: CupertinoListTile(
title: Text(AppLocalizations.of(context).player),
trailing: Row(
children: [
SizedBox(
width: 100,
child: Center(
child: Text(
AppLocalizations.of(context).points))),
const SizedBox(width: 20),
SizedBox(
width: 80,
child: Center(
child: Text(AppLocalizations.of(context)
.kamikaze))),
],
),
),
),
ListView.builder( ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -182,13 +164,15 @@ class _RoundViewState extends State<RoundView> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final originalIndex = originalIndices[index]; final originalIndex = originalIndices[index];
final name = rotatedPlayers[index]; final name = rotatedPlayers[index];
bool shouldShowMedal =
index == 0 && widget.roundNumber > 1;
return Padding( return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 20), vertical: 10, horizontal: 20),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CupertinoListTile( child: CupertinoListTile(
backgroundColor: CupertinoColors.secondaryLabel, backgroundColor: CustomTheme.playerTileColor,
title: Row(children: [ title: Row(children: [
Expanded( Expanded(
child: Row(children: [ child: Row(children: [
@@ -197,95 +181,70 @@ class _RoundViewState extends State<RoundView> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Visibility( Visibility(
visible: index == 0, visible: shouldShowMedal,
child: const SizedBox(width: 10), child: const SizedBox(width: 10),
), ),
Visibility( Visibility(
visible: index == 0, visible: shouldShowMedal,
child: const Icon(FontAwesomeIcons.medal, child: const Icon(FontAwesomeIcons.crown,
size: 15)) size: 15))
])) ]))
]), ]),
subtitle: Text( subtitle: Text(
'${widget.gameSession.playerScores[originalIndex]}' '${widget.gameSession.playerScores[originalIndex]}'
' ${AppLocalizations.of(context).points}'), ' ${AppLocalizations.of(context).points}'),
trailing: Row( trailing: SizedBox(
children: [ width: 100,
SizedBox( child: CupertinoTextField(
width: 100, maxLength: 3,
child: CupertinoTextField( focusNode: _focusNodeList[originalIndex],
maxLength: 3, keyboardType:
focusNode: _focusNodeList[originalIndex], const TextInputType.numberWithOptions(
keyboardType: signed: true,
const TextInputType.numberWithOptions( decimal: false,
signed: true,
decimal: false,
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
textInputAction: index ==
widget.gameSession.players
.length -
1
? TextInputAction.done
: TextInputAction.next,
controller:
_scoreControllerList[originalIndex],
placeholder:
AppLocalizations.of(context).points,
textAlign: TextAlign.center,
onSubmitted: (_) =>
_focusNextTextfield(originalIndex),
onChanged: (_) => setState(() {}),
),
), ),
const SizedBox(width: 50), inputFormatters: [
GestureDetector( FilteringTextInputFormatter.digitsOnly,
onTap: () { ],
setState(() { textInputAction: index ==
_kamikazePlayerIndex = widget.gameSession.players.length - 1
(_kamikazePlayerIndex == ? TextInputAction.done
originalIndex) : TextInputAction.next,
? null controller:
: originalIndex; _scoreControllerList[originalIndex],
}); placeholder:
}, AppLocalizations.of(context).points,
child: Container( textAlign: TextAlign.center,
width: 24, onSubmitted: (_) =>
height: 24, _focusNextTextfield(originalIndex),
decoration: BoxDecoration( onChanged: (_) => setState(() {}),
shape: BoxShape.circle, ),
color: _kamikazePlayerIndex ==
originalIndex
? CupertinoColors.systemRed
: CupertinoColors
.tertiarySystemFill,
border: Border.all(
color: _kamikazePlayerIndex ==
originalIndex
? CupertinoColors.systemRed
: CupertinoColors.systemGrey,
),
),
child: _kamikazePlayerIndex ==
originalIndex
? const Icon(
CupertinoIcons.exclamationmark,
size: 16,
color: CupertinoColors.white,
)
: null,
),
),
const SizedBox(width: 22),
],
), ),
), ),
), ),
); );
}, },
), ),
Padding(
padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
child: Center(
heightFactor: 1,
child: CustomButton(
onPressed: () async {
if (await _showKamikazeSheet(context)) {
if (!context.mounted) return;
_endOfRoundNavigation(context, true);
}
},
child: Text(
AppLocalizations.of(context).kamikaze,
style: const TextStyle(
color: CupertinoColors.destructiveRed,
),
),
),
),
),
], ],
), ),
), ),
@@ -300,21 +259,14 @@ class _RoundViewState extends State<RoundView> {
return Container( return Container(
height: 80, height: 80,
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
color: CustomTheme.backgroundTintColor, color: CustomTheme.mainElementBackgroundColor,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
CupertinoButton( CupertinoButton(
onPressed: _areRoundInputsValid() onPressed: _areRoundInputsValid()
? () async { ? () {
List<int> bonusPlayersIndices = _finishRound(); _endOfRoundNavigation(context, false);
if (bonusPlayersIndices.isNotEmpty) {
await _showBonusPopup(
context, bonusPlayersIndices);
}
LocalStorageService.saveGameSessions();
if (!context.mounted) return;
Navigator.pop(context);
} }
: null, : null,
child: Text(AppLocalizations.of(context).done), child: Text(AppLocalizations.of(context).done),
@@ -322,21 +274,8 @@ class _RoundViewState extends State<RoundView> {
if (!widget.gameSession.isGameFinished) if (!widget.gameSession.isGameFinished)
CupertinoButton( CupertinoButton(
onPressed: _areRoundInputsValid() onPressed: _areRoundInputsValid()
? () async { ? () {
List<int> bonusPlayersIndices = _endOfRoundNavigation(context, true);
_finishRound();
if (bonusPlayersIndices.isNotEmpty) {
await _showBonusPopup(
context, bonusPlayersIndices);
}
LocalStorageService.saveGameSessions();
if (widget.gameSession.isGameFinished &&
context.mounted) {
Navigator.pop(context);
} else if (context.mounted) {
Navigator.pop(
context, widget.roundNumber + 1);
}
} }
: null, : null,
child: Text(AppLocalizations.of(context).next_round), child: Text(AppLocalizations.of(context).next_round),
@@ -399,6 +338,37 @@ class _RoundViewState extends State<RoundView> {
]; ];
} }
/// Shows a Cupertino action sheet to select the player who has Kamikaze.
/// It returns true if a player was selected, false if the action was cancelled.
Future<bool> _showKamikazeSheet(BuildContext context) async {
return await showCupertinoModalPopup<bool?>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
title: Text(AppLocalizations.of(context).kamikaze),
message: Text(AppLocalizations.of(context).who_has_kamikaze),
actions: widget.gameSession.players.asMap().entries.map((entry) {
final index = entry.key;
final name = entry.value;
return CupertinoActionSheetAction(
onPressed: () {
_kamikazePlayerIndex = index;
Navigator.pop(context, true);
},
child: Text(name),
);
}).toList(),
cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.pop(context, false),
isDestructiveAction: true,
child: Text(AppLocalizations.of(context).cancel),
),
);
},
) ??
false;
}
/// Focuses the next text field in the list of text fields. /// Focuses the next text field in the list of text fields.
/// [index] is the index of the current text field. /// [index] is the index of the current text field.
void _focusNextTextfield(int index) { void _focusNextTextfield(int index) {
@@ -469,10 +439,9 @@ class _RoundViewState extends State<RoundView> {
return bonusPlayers; return bonusPlayers;
} }
/// Shows a popup dialog with the bonus information. /// Shows a popup dialog with the information which player received the bonus points.
Future<void> _showBonusPopup( Future<void> _showBonusPopup(
BuildContext context, List<int> bonusPlayers) async { BuildContext context, List<int> bonusPlayers) async {
print('Bonus Popup wird angezeigt');
int pointLimit = widget.gameSession.pointLimit; int pointLimit = widget.gameSession.pointLimit;
int bonusPoints = (pointLimit / 2).round(); int bonusPoints = (pointLimit / 2).round();
@@ -519,6 +488,37 @@ class _RoundViewState extends State<RoundView> {
return resultText; return resultText;
} }
/// Handles the navigation for the end of the round.
/// It checks for bonus players and shows a popup, saves the game session,
/// and navigates to the next round or back to the previous screen.
/// It takes the BuildContext [context] and a boolean [navigateToNextRound] to determine
/// if it should navigate to the next round or not.
Future<void> _endOfRoundNavigation(
BuildContext context, bool navigateToNextRound) async {
List<int> bonusPlayersIndices = _finishRound();
if (bonusPlayersIndices.isNotEmpty) {
await _showBonusPopup(context, bonusPlayersIndices);
}
LocalStorageService.saveGameSessions();
if (context.mounted) {
// If the game is finished, pop the context and return to the previous screen.
if (widget.gameSession.isGameFinished) {
Navigator.pop(context);
return;
}
// If navigateToNextRound is false, pop the context and return to the previous screen.
if (!navigateToNextRound) {
Navigator.pop(context);
return;
}
// If navigateToNextRound is true and the game isn't finished yet,
// pop the context and navigate to the next round.
Navigator.pop(context, widget.roundNumber + 1);
}
}
@override @override
void dispose() { void dispose() {
for (final controller in _scoreControllerList) { for (final controller in _scoreControllerList) {

View File

@@ -1,6 +1,7 @@
import 'package:cabo_counter/core/constants.dart'; import 'package:cabo_counter/core/constants.dart';
import 'package:cabo_counter/core/custom_theme.dart'; import 'package:cabo_counter/core/custom_theme.dart';
import 'package:cabo_counter/l10n/generated/app_localizations.dart'; import 'package:cabo_counter/l10n/generated/app_localizations.dart';
import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:cabo_counter/presentation/widgets/custom_form_row.dart'; import 'package:cabo_counter/presentation/widgets/custom_form_row.dart';
import 'package:cabo_counter/presentation/widgets/custom_stepper.dart'; import 'package:cabo_counter/presentation/widgets/custom_stepper.dart';
import 'package:cabo_counter/services/config_service.dart'; import 'package:cabo_counter/services/config_service.dart';
@@ -20,6 +21,7 @@ class SettingsView extends StatefulWidget {
class _SettingsViewState extends State<SettingsView> { class _SettingsViewState extends State<SettingsView> {
UniqueKey _stepperKey1 = UniqueKey(); UniqueKey _stepperKey1 = UniqueKey();
UniqueKey _stepperKey2 = UniqueKey(); UniqueKey _stepperKey2 = UniqueKey();
GameMode defaultMode = ConfigService.getGameMode();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -55,14 +57,13 @@ class _SettingsViewState extends State<SettingsView> {
prefixIcon: CupertinoIcons.bolt_fill, prefixIcon: CupertinoIcons.bolt_fill,
suffixWidget: CustomStepper( suffixWidget: CustomStepper(
key: _stepperKey1, key: _stepperKey1,
initialValue: ConfigService.caboPenalty, initialValue: ConfigService.getCaboPenalty(),
minValue: 0, minValue: 0,
maxValue: 50, maxValue: 50,
step: 1, step: 1,
onChanged: (newCaboPenalty) { onChanged: (newCaboPenalty) {
setState(() { setState(() {
ConfigService.setCaboPenalty(newCaboPenalty); ConfigService.setCaboPenalty(newCaboPenalty);
ConfigService.caboPenalty = newCaboPenalty;
}); });
}, },
), ),
@@ -72,18 +73,51 @@ class _SettingsViewState extends State<SettingsView> {
prefixIcon: FontAwesomeIcons.bullseye, prefixIcon: FontAwesomeIcons.bullseye,
suffixWidget: CustomStepper( suffixWidget: CustomStepper(
key: _stepperKey2, key: _stepperKey2,
initialValue: ConfigService.pointLimit, initialValue: ConfigService.getPointLimit(),
minValue: 30, minValue: 30,
maxValue: 1000, maxValue: 1000,
step: 10, step: 10,
onChanged: (newPointLimit) { onChanged: (newPointLimit) {
setState(() { setState(() {
ConfigService.setPointLimit(newPointLimit); ConfigService.setPointLimit(newPointLimit);
ConfigService.pointLimit = newPointLimit;
}); });
}, },
), ),
), ),
CustomFormRow(
prefixText: AppLocalizations.of(context).standard_mode,
prefixIcon: CupertinoIcons.square_stack,
suffixWidget: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
defaultMode == GameMode.none
? AppLocalizations.of(context).no_default_mode
: (defaultMode == GameMode.pointLimit
? '${ConfigService.getPointLimit()} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 5),
const CupertinoListTileChevron()
],
),
onPressed: () async {
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: ConfigService.getPointLimit(),
showDeselection: true,
),
),
);
setState(() {
defaultMode = selectedMode ?? GameMode.none;
});
ConfigService.setGameMode(defaultMode);
},
),
CustomFormRow( CustomFormRow(
prefixText: prefixText:
AppLocalizations.of(context).reset_to_default, AppLocalizations.of(context).reset_to_default,
@@ -93,6 +127,7 @@ class _SettingsViewState extends State<SettingsView> {
setState(() { setState(() {
_stepperKey1 = UniqueKey(); _stepperKey1 = UniqueKey();
_stepperKey2 = UniqueKey(); _stepperKey2 = UniqueKey();
defaultMode = ConfigService.getGameMode();
}); });
}, },
) )

View File

@@ -16,8 +16,9 @@ class _TabViewState extends State<TabView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CupertinoTabScaffold( return CupertinoTabScaffold(
resizeToAvoidBottomInset: false,
tabBar: CupertinoTabBar( tabBar: CupertinoTabBar(
backgroundColor: CustomTheme.backgroundTintColor, backgroundColor: CustomTheme.mainElementBackgroundColor,
iconSize: 27, iconSize: 27,
height: 55, height: 55,
items: <BottomNavigationBarItem>[ items: <BottomNavigationBarItem>[

View File

@@ -0,0 +1,19 @@
import 'package:cabo_counter/core/custom_theme.dart';
import 'package:flutter/cupertino.dart';
class CustomButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
const CustomButton({super.key, required this.child, this.onPressed});
@override
Widget build(BuildContext context) {
return CupertinoButton(
sizeStyle: CupertinoButtonSize.medium,
borderRadius: BorderRadius.circular(12),
color: CustomTheme.buttonBackgroundColor,
onPressed: onPressed,
child: child,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:cabo_counter/presentation/views/mode_selection_view.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// This class handles the configuration settings for the app. /// This class handles the configuration settings for the app.
@@ -6,53 +7,101 @@ import 'package:shared_preferences/shared_preferences.dart';
class ConfigService { class ConfigService {
static const String _keyPointLimit = 'pointLimit'; static const String _keyPointLimit = 'pointLimit';
static const String _keyCaboPenalty = 'caboPenalty'; static const String _keyCaboPenalty = 'caboPenalty';
static const String _keyGameMode = 'gameMode';
// Actual values used in the app // Actual values used in the app
static int pointLimit = 100; static int _pointLimit = 100;
static int caboPenalty = 5; static int _caboPenalty = 5;
static int _gameMode = -1;
// Default values // Default values
static const int _defaultPointLimit = 100; static const int _defaultPointLimit = 100;
static const int _defaultCaboPenalty = 5; static const int _defaultCaboPenalty = 5;
static const int _defaultGameMode = -1;
static Future<void> initConfig() async { static Future<void> initConfig() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Default values only set if they are not already set // Initialize pointLimit, caboPenalty, and gameMode from SharedPreferences
prefs.setInt( // If they are not set, use the default values
_keyPointLimit, prefs.getInt(_keyPointLimit) ?? _defaultPointLimit); _pointLimit = prefs.getInt(_keyPointLimit) ?? _defaultPointLimit;
prefs.setInt( _caboPenalty = prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty;
_keyCaboPenalty, prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty); _gameMode = prefs.getInt(_keyGameMode) ?? _defaultGameMode;
// Save the initial values to SharedPreferences
prefs.setInt(_keyPointLimit, _pointLimit);
prefs.setInt(_keyCaboPenalty, _caboPenalty);
prefs.setInt(_keyGameMode, _gameMode);
} }
/// Getter for the point limit. /// Retrieves the current game mode.
static Future<int> getPointLimit() async { ///
final prefs = await SharedPreferences.getInstance(); /// The game mode is determined based on the stored integer value:
return prefs.getInt(_keyPointLimit) ?? _defaultPointLimit; /// - `0`: [GameMode.pointLimit]
/// - `1`: [GameMode.unlimited]
/// - Any other value: [GameMode.none] (-1 is used as a default for no mode)
///
/// Returns the corresponding [GameMode] enum value.
static GameMode getGameMode() {
switch (_gameMode) {
case 0:
return GameMode.pointLimit;
case 1:
return GameMode.unlimited;
default:
return GameMode.none;
}
} }
/// Sets the game mode for the application.
///
/// [newGameMode] is the new game mode to be set. It can be one of the following:
/// - `GameMode.pointLimit`: The game ends when a pleayer reaches the point limit.
/// - `GameMode.unlimited`: Every game goes for infinity until you end it.
/// - `GameMode.none`: No default mode set.
///
/// This method updates the `_gameMode` field and persists the value in `SharedPreferences`.
static Future<void> setGameMode(GameMode newGameMode) async {
int gameMode;
switch (newGameMode) {
case GameMode.pointLimit:
gameMode = 0;
break;
case GameMode.unlimited:
gameMode = 1;
break;
default:
gameMode = -1;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyGameMode, gameMode);
_gameMode = gameMode;
}
static int getPointLimit() => _pointLimit;
/// Setter for the point limit. /// Setter for the point limit.
/// [newPointLimit] is the new point limit to be set. /// [newPointLimit] is the new point limit to be set.
static Future<void> setPointLimit(int newPointLimit) async { static Future<void> setPointLimit(int newPointLimit) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyPointLimit, newPointLimit); await prefs.setInt(_keyPointLimit, newPointLimit);
_pointLimit = newPointLimit;
} }
/// Getter for the cabo penalty. static int getCaboPenalty() => _caboPenalty;
static Future<int> getCaboPenalty() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_keyCaboPenalty) ?? _defaultCaboPenalty;
}
/// Setter for the cabo penalty. /// Setter for the cabo penalty.
/// [newCaboPenalty] is the new cabo penalty to be set. /// [newCaboPenalty] is the new cabo penalty to be set.
static Future<void> setCaboPenalty(int newCaboPenalty) async { static Future<void> setCaboPenalty(int newCaboPenalty) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyCaboPenalty, newCaboPenalty); await prefs.setInt(_keyCaboPenalty, newCaboPenalty);
_caboPenalty = newCaboPenalty;
} }
/// Resets the configuration to default values. /// Resets the configuration to default values.
static Future<void> resetConfig() async { static Future<void> resetConfig() async {
ConfigService.pointLimit = _defaultPointLimit; ConfigService._pointLimit = _defaultPointLimit;
ConfigService.caboPenalty = _defaultCaboPenalty; ConfigService._caboPenalty = _defaultCaboPenalty;
ConfigService._gameMode = _defaultGameMode;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyPointLimit, _defaultPointLimit); await prefs.setInt(_keyPointLimit, _defaultPointLimit);
await prefs.setInt(_keyCaboPenalty, _defaultCaboPenalty); await prefs.setInt(_keyCaboPenalty, _defaultCaboPenalty);

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo" description: "Mobile app for the card game Cabo"
publish_to: 'none' publish_to: 'none'
version: 0.4.7+506 version: 0.5.3+594
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -28,6 +28,9 @@ dependencies:
syncfusion_flutter_charts: ^30.1.37 syncfusion_flutter_charts: ^30.1.37
uuid: ^4.5.1 uuid: ^4.5.1
rate_my_app: ^2.3.2 rate_my_app: ^2.3.2
reorderables: ^0.4.2
collection: ^1.18.0
confetti: ^0.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -9,6 +9,7 @@ void main() {
setUp(() { setUp(() {
session = GameSession( session = GameSession(
id: '1',
createdAt: testDate, createdAt: testDate,
gameTitle: testTitle, gameTitle: testTitle,
players: testPlayers, players: testPlayers,