Merge pull request #85 from flixcoo/develop

v0.3.8-beta
This commit is contained in:
2025-07-08 22:28:05 +02:00
committed by GitHub
24 changed files with 1374 additions and 308 deletions

View File

@@ -1,8 +1,8 @@
# CABO Counter
![Version](https://img.shields.io/badge/Version-0.3.0-orange)
![Flutter](https://img.shields.io/badge/Flutter-3.24.5-blue?logo=flutter)
![Dart](https://img.shields.io/badge/Dart-3.5.4-blue?logo=dart)
![Flutter](https://img.shields.io/badge/Flutter-3.32.1-blue?logo=flutter)
![Dart](https://img.shields.io/badge/Dart-3.8.1-blue?logo=dart)
![iOS](https://img.shields.io/badge/iOS-18.5-white?logo=apple)
![GitHub Issues](https://img.shields.io/github/issues/flixcoo/Cabo-Counter?logo=github)
![GitHub Pull Requests](https://img.shields.io/github/issues-pr/flixcoo/Cabo-Counter?logo=github)

291
assets/game-schema.json Normal file
View File

@@ -0,0 +1,291 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Generated schema for a single cabo counter game",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"gameTitle": {
"type": "string"
},
"players": {
"type": "array",
"items": [
{
"type": "string"
},
{
"type": "string"
}
]
},
"pointLimit": {
"type": "integer"
},
"caboPenalty": {
"type": "integer"
},
"isPointsLimitEnabled": {
"type": "boolean"
},
"isGameFinished": {
"type": "boolean"
},
"winner": {
"type": "string"
},
"roundNumber": {
"type": "integer"
},
"playerScores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"roundList": {
"type": "array",
"items": [
{
"type": "object",
"properties": {
"roundNum": {
"type": "integer"
},
"caboPlayerIndex": {
"type": "integer"
},
"kamikazePlayerIndex": {
"type": "null"
},
"scores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"scoreUpdates": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
}
},
"required": [
"roundNum",
"caboPlayerIndex",
"kamikazePlayerIndex",
"scores",
"scoreUpdates"
]
},
{
"type": "object",
"properties": {
"roundNum": {
"type": "integer"
},
"caboPlayerIndex": {
"type": "integer"
},
"kamikazePlayerIndex": {
"type": "null"
},
"scores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"scoreUpdates": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
}
},
"required": [
"roundNum",
"caboPlayerIndex",
"kamikazePlayerIndex",
"scores",
"scoreUpdates"
]
},
{
"type": "object",
"properties": {
"roundNum": {
"type": "integer"
},
"caboPlayerIndex": {
"type": "integer"
},
"kamikazePlayerIndex": {
"type": "null"
},
"scores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"scoreUpdates": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
}
},
"required": [
"roundNum",
"caboPlayerIndex",
"kamikazePlayerIndex",
"scores",
"scoreUpdates"
]
},
{
"type": "object",
"properties": {
"roundNum": {
"type": "integer"
},
"caboPlayerIndex": {
"type": "integer"
},
"kamikazePlayerIndex": {
"type": "null"
},
"scores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"scoreUpdates": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
}
},
"required": [
"roundNum",
"caboPlayerIndex",
"kamikazePlayerIndex",
"scores",
"scoreUpdates"
]
},
{
"type": "object",
"properties": {
"roundNum": {
"type": "integer"
},
"caboPlayerIndex": {
"type": "integer"
},
"kamikazePlayerIndex": {
"type": "null"
},
"scores": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
},
"scoreUpdates": {
"type": "array",
"items": [
{
"type": "integer"
},
{
"type": "integer"
}
]
}
},
"required": [
"roundNum",
"caboPlayerIndex",
"kamikazePlayerIndex",
"scores",
"scoreUpdates"
]
}
]
}
},
"required": [
"id",
"createdAt",
"gameTitle",
"players",
"pointLimit",
"caboPenalty",
"isPointsLimitEnabled",
"isGameFinished",
"winner",
"roundNumber",
"playerScores",
"roundList"
]
}

View File

@@ -1,10 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Generated schema for cabo game data",
"title": "Generated schema for the cabo counter game data",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -293,14 +293,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@@ -15,14 +15,9 @@ class GameManager extends ChangeNotifier {
notifyListeners(); // Propagate session changes
});
gameList.add(session);
print(
'[game_manager.dart] Added game session: ${session.gameTitle} at ${session.createdAt}');
gameList.sort((a, b) => b.createdAt.compareTo(a.createdAt));
print(
'[game_manager.dart] Sorted game sessions by creation date. Total sessions: ${gameList.length}');
notifyListeners();
await LocalStorageService.saveGameSessions();
print('[game_manager.dart] Saved game sessions to local storage.');
return gameList.indexOf(session);
}
@@ -30,12 +25,44 @@ class GameManager extends ChangeNotifier {
/// 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.
/// It also saves the updated game sessions to local storage.
void removeGameSession(int index) {
void removeGameSessionByIndex(int index) {
gameList[index].removeListener(notifyListeners);
gameList.removeAt(index);
notifyListeners();
LocalStorageService.saveGameSessions();
}
/// Removes a game session by its ID.
/// Takes a String [id] as input. It finds the index of the game session with the matching ID
/// in the `gameList`, and then calls `removeGameSessionByIndex` with that index.
void removeGameSessionById(String id) {
final int index =
gameList.indexWhere((session) => session.id.toString() == id);
if (index == -1) return;
removeGameSessionByIndex(index);
}
/// Retrieves a game session by its ID.
/// Takes a String [id] as input. It finds the game session with the matching id
bool gameExistsInGameList(String id) {
return gameList.any((session) => session.id.toString() == id);
}
/// Ends a game session if its in unlimited mode.
/// Takes a String [id] as input. It finds the index of the game
/// session with the matching ID marks it as finished,
void endGame(String id) {
final int index =
gameList.indexWhere((session) => session.id.toString() == id);
// Game session not found or not in unlimited mode
if (index == -1 || gameList[index].isPointsLimitEnabled == true) return;
gameList[index].roundNumber--;
gameList[index].isGameFinished = true;
notifyListeners();
LocalStorageService.saveGameSessions();
}
}
final gameManager = GameManager();

View File

@@ -1,5 +1,6 @@
import 'package:cabo_counter/data/round.dart';
import 'package:flutter/cupertino.dart';
import 'package:uuid/uuid.dart';
/// This class represents a game session for Cabo game.
/// [createdAt] is the timestamp of when the game session was created.
@@ -12,6 +13,7 @@ import 'package:flutter/cupertino.dart';
/// [isGameFinished] is a boolean indicating if the game has ended yet.
/// [winner] is the name of the player who won the game.
class GameSession extends ChangeNotifier {
late String id;
final DateTime createdAt;
final String gameTitle;
final List<String> players;
@@ -33,17 +35,20 @@ class GameSession extends ChangeNotifier {
required this.isPointsLimitEnabled,
}) {
playerScores = List.filled(players.length, 0);
var uuid = const Uuid();
id = uuid.v1();
}
@override
toString() {
return ('GameSession: [createdAt: $createdAt, gameTitle: $gameTitle, '
return ('GameSession: [id: $id, createdAt: $createdAt, gameTitle: $gameTitle, '
'isPointsLimitEnabled: $isPointsLimitEnabled, pointLimit: $pointLimit, caboPenalty: $caboPenalty,'
' players: $players, playerScores: $playerScores, roundList: $roundList, winner: $winner]');
}
/// Converts the GameSession object to a JSON map.
Map<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt.toIso8601String(),
'gameTitle': gameTitle,
'players': players,
@@ -59,7 +64,8 @@ class GameSession extends ChangeNotifier {
/// Creates a GameSession object from a JSON map.
GameSession.fromJson(Map<String, dynamic> json)
: createdAt = DateTime.parse(json['createdAt']),
: id = json['id'] ?? const Uuid().v1(),
createdAt = DateTime.parse(json['createdAt']),
gameTitle = json['gameTitle'],
players = List<String>.from(json['players']),
pointLimit = json['pointLimit'],
@@ -72,11 +78,13 @@ class GameSession extends ChangeNotifier {
roundList =
(json['roundList'] as List).map((e) => Round.fromJson(e)).toList();
/// Returns the length of all player names combined.
int getLengthOfPlayerNames() {
/// Returns the length of the longest player name.
int getMaxLengthOfPlayerNames() {
int length = 0;
for (String player in players) {
length += player.length;
if (player.length >= length) {
length = player.length;
}
}
return length;
}

View File

@@ -14,6 +14,7 @@
"player": "Spieler:in",
"players": "Spieler:innen",
"name": "Name",
"back": "Zurück",
"home": "Home",
"about": "Über",
@@ -21,7 +22,7 @@
"empty_text_1": "Ganz schön leer hier...",
"empty_text_2": "Füge über den Button oben rechts eine neue Runde hinzu",
"delete_game_title": "Spiel löschen?",
"delete_game_message": "Bist du sicher, dass du die Runde {gameTitle} löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_game_message": "Bist du sicher, dass du das Spiel \"{gameTitle}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"@delete_game_message": {
"placeholders": {
"gameTitle": {
@@ -63,6 +64,18 @@
"done": "Fertig",
"next_round": "Nächste Runde",
"statistics": "Statistiken",
"end_game": "Spiel beenden",
"delete_game": "Spiel löschen",
"new_game_same_settings": "Neues Spiel mit gleichen Einstellungen",
"export_game": "Spiel exportieren",
"id_error_title": "ID Fehler",
"id_error_message": "Das Spiel hat bisher noch keine ID zugewiesen bekommen. Falls du das Spiel löschen möchtest, mache das bitte über das Hauptmenü. Alle neu erstellten Spiele haben eine ID.",
"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.",
"game_process": "Spielverlauf",
"settings": "Einstellungen",
"cabo_penalty": "Cabo-Strafe",
"cabo_penalty_subtitle": "... für falsches Cabo sagen",
@@ -72,9 +85,18 @@
"game_data": "Spieldaten",
"import_data": "Daten importieren",
"export_data": "Daten exportieren",
"error": "Fehler",
"error_import": "Datei konnte nicht importiert werden",
"error_export": "Datei konnte nicht exportiert werden",
"import_success_title": "Import erfolgreich",
"import_success_message":"Die Spieldaten wurden erfolgreich importiert.",
"import_validation_error_title": "Validierung fehlgeschlagen",
"import_validation_error_message": "Es wurden keine Cabo-Counter Spieldaten gefunden. Bitte stellen Sie sicher, dass es sich um eine gültige Cabo-Counter Exportdatei handelt.",
"import_format_error_title": "Falsches Format",
"import_format_error_message": "Die Datei ist kein gültiges JSON-Format oder enthält ungültige Daten.",
"import_generic_error_title": "Import fehlgeschlagen",
"import_generic_error_message": "Der Import ist fehlgeschlagen.",
"export_error_title": "Fehler",
"export_error_message": "Datei konnte nicht exportiert werden",
"error_found": "Fehler gefunden?",
"create_issue": "Issue erstellen",
"app_version": "App-Version",

View File

@@ -14,14 +14,15 @@
"player": "Player",
"players": "Players",
"name": "Name",
"back": "Back",
"home": "Home",
"about": "About",
"empty_text_1": "Pretty empty here...",
"empty_text_2": "Add a new round using the button in the top right corner",
"empty_text_2": "Add a new round using the button in the top right corner.",
"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": {
"placeholders": {
"gameTitle": {
@@ -63,22 +64,44 @@
"done": "Done",
"next_round": "Next Round",
"statistics": "Statistics",
"end_game": "End Game",
"delete_game": "Delete Game",
"new_game_same_settings": "New Game with same Settings",
"export_game": "Export Game",
"game_process": "Spielverlauf",
"settings": "Settings",
"cabo_penalty": "Cabo Penalty",
"cabo_penalty_subtitle": "... for falsely calling Cabo",
"cabo_penalty_subtitle": "... for falsely calling Cabo.",
"point_limit": "Point Limit",
"point_limit_subtitle": "... the game ends here",
"point_limit_subtitle": "... the game ends here.",
"reset_to_default": "Reset to Default",
"game_data": "Game Data",
"import_data": "Import Data",
"export_data": "Export Data",
"error": "Error",
"error_import": "Could not import file",
"error_export": "Could not export file",
"id_error_title": "ID Error",
"id_error_message": "The game has not yet been assigned an ID. If you want to delete the game, please do so via the main menu. All newly created games have an ID.",
"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.",
"import_success_title": "Import successful",
"import_success_message":"The game data has been successfully imported.",
"import_validation_error_title": "Validation failed",
"import_validation_error_message": "No Cabo-Counter game data was found. Please make sure that this is a valid Cabo-Counter export file.",
"import_format_error_title": "Wrong format",
"import_format_error_message": "The file is not a valid JSON format or contains invalid data.",
"import_generic_error_title": "Import failed",
"import_generic_error_message": "The import has failed.",
"export_error_title": "Export failed",
"export_error_message": "Could not export file",
"error_found": "Found a bug?",
"create_issue": "Create Issue",
"app_version": "App Version",
"load_version": "Loading version...",
"build": "Build",
"about_text": "Hey :) Thanks for being one of the first users of my first app! Ive put a lot of work into this project, and even though I (hopefully) thought of a lot, not everything will work 100% yet. So if you discover any bugs or have feedback on the design or usability, please let me know via the Testflight app or a message / email. Thank you very much!"
}
"about_text": "Hey :) Thanks for being one of the first users of my app! Ive put a lot of work into this project, and even though I tried to think of everything, it might not work perfectly just yet. So if you discover any bugs or have feedback on the design or usability, please let me know via the TestFlight app or by sending me a message or email. Thank you very much!"
}

View File

@@ -176,6 +176,12 @@ abstract class AppLocalizations {
/// **'Name'**
String get name;
/// No description provided for @back.
///
/// In de, this message translates to:
/// **'Zurück'**
String get back;
/// No description provided for @home.
///
/// In de, this message translates to:
@@ -209,7 +215,7 @@ abstract class AppLocalizations {
/// No description provided for @delete_game_message.
///
/// In de, this message translates to:
/// **'Bist du sicher, dass du die Runde {gameTitle} löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.'**
/// **'Bist du sicher, dass du das Spiel \"{gameTitle}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.'**
String delete_game_message(String gameTitle);
/// No description provided for @overview.
@@ -356,6 +362,66 @@ abstract class AppLocalizations {
/// **'Nächste Runde'**
String get next_round;
/// No description provided for @statistics.
///
/// In de, this message translates to:
/// **'Statistiken'**
String get statistics;
/// No description provided for @end_game.
///
/// In de, this message translates to:
/// **'Spiel beenden'**
String get end_game;
/// No description provided for @delete_game.
///
/// In de, this message translates to:
/// **'Spiel löschen'**
String get delete_game;
/// No description provided for @new_game_same_settings.
///
/// In de, this message translates to:
/// **'Neues Spiel mit gleichen Einstellungen'**
String get new_game_same_settings;
/// No description provided for @export_game.
///
/// In de, this message translates to:
/// **'Spiel exportieren'**
String get export_game;
/// No description provided for @id_error_title.
///
/// In de, this message translates to:
/// **'ID Fehler'**
String get id_error_title;
/// No description provided for @id_error_message.
///
/// In de, this message translates to:
/// **'Das Spiel hat bisher noch keine ID zugewiesen bekommen. Falls du das Spiel löschen möchtest, mache das bitte über das Hauptmenü. Alle neu erstellten Spiele haben eine ID.'**
String get id_error_message;
/// No description provided for @end_game_title.
///
/// In de, this message translates to:
/// **'Spiel beenden?'**
String get end_game_title;
/// No description provided for @end_game_message.
///
/// In de, this message translates to:
/// **'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.'**
String get end_game_message;
/// No description provided for @game_process.
///
/// In de, this message translates to:
/// **'Spielverlauf'**
String get game_process;
/// No description provided for @settings.
///
/// In de, this message translates to:
@@ -410,23 +476,65 @@ abstract class AppLocalizations {
/// **'Daten exportieren'**
String get export_data;
/// No description provided for @error.
/// No description provided for @import_success_title.
///
/// In de, this message translates to:
/// **'Import erfolgreich'**
String get import_success_title;
/// No description provided for @import_success_message.
///
/// In de, this message translates to:
/// **'Die Spieldaten wurden erfolgreich importiert.'**
String get import_success_message;
/// No description provided for @import_validation_error_title.
///
/// In de, this message translates to:
/// **'Validierung fehlgeschlagen'**
String get import_validation_error_title;
/// No description provided for @import_validation_error_message.
///
/// In de, this message translates to:
/// **'Es wurden keine Cabo-Counter Spieldaten gefunden. Bitte stellen Sie sicher, dass es sich um eine gültige Cabo-Counter Exportdatei handelt.'**
String get import_validation_error_message;
/// No description provided for @import_format_error_title.
///
/// In de, this message translates to:
/// **'Falsches Format'**
String get import_format_error_title;
/// No description provided for @import_format_error_message.
///
/// In de, this message translates to:
/// **'Die Datei ist kein gültiges JSON-Format oder enthält ungültige Daten.'**
String get import_format_error_message;
/// No description provided for @import_generic_error_title.
///
/// In de, this message translates to:
/// **'Import fehlgeschlagen'**
String get import_generic_error_title;
/// No description provided for @import_generic_error_message.
///
/// In de, this message translates to:
/// **'Der Import ist fehlgeschlagen.'**
String get import_generic_error_message;
/// No description provided for @export_error_title.
///
/// In de, this message translates to:
/// **'Fehler'**
String get error;
String get export_error_title;
/// No description provided for @error_import.
///
/// In de, this message translates to:
/// **'Datei konnte nicht importiert werden'**
String get error_import;
/// No description provided for @error_export.
/// No description provided for @export_error_message.
///
/// In de, this message translates to:
/// **'Datei konnte nicht exportiert werden'**
String get error_export;
String get export_error_message;
/// No description provided for @error_found.
///

View File

@@ -47,6 +47,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get name => 'Name';
@override
String get back => 'Zurück';
@override
String get home => 'Home';
@@ -65,7 +68,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String delete_game_message(String gameTitle) {
return 'Bist du sicher, dass du die Runde $gameTitle löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.';
return 'Bist du sicher, dass du das Spiel \"$gameTitle\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.';
}
@override
@@ -146,6 +149,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get next_round => 'Nächste Runde';
@override
String get statistics => 'Statistiken';
@override
String get end_game => 'Spiel beenden';
@override
String get delete_game => 'Spiel löschen';
@override
String get new_game_same_settings => 'Neues Spiel mit gleichen Einstellungen';
@override
String get export_game => 'Spiel exportieren';
@override
String get id_error_title => 'ID Fehler';
@override
String get id_error_message =>
'Das Spiel hat bisher noch keine ID zugewiesen bekommen. Falls du das Spiel löschen möchtest, mache das bitte über das Hauptmenü. Alle neu erstellten Spiele haben eine ID.';
@override
String get end_game_title => 'Spiel beenden?';
@override
String get end_game_message =>
'Möchtest du das Spiel beenden? Das Spiel wird als beendet markiert und kann nicht fortgeführt werden.';
@override
String get game_process => 'Spielverlauf';
@override
String get settings => 'Einstellungen';
@@ -174,13 +209,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get export_data => 'Daten exportieren';
@override
String get error => 'Fehler';
String get import_success_title => 'Import erfolgreich';
@override
String get error_import => 'Datei konnte nicht importiert werden';
String get import_success_message =>
'Die Spieldaten wurden erfolgreich importiert.';
@override
String get error_export => 'Datei konnte nicht exportiert werden';
String get import_validation_error_title => 'Validierung fehlgeschlagen';
@override
String get import_validation_error_message =>
'Es wurden keine Cabo-Counter Spieldaten gefunden. Bitte stellen Sie sicher, dass es sich um eine gültige Cabo-Counter Exportdatei handelt.';
@override
String get import_format_error_title => 'Falsches Format';
@override
String get import_format_error_message =>
'Die Datei ist kein gültiges JSON-Format oder enthält ungültige Daten.';
@override
String get import_generic_error_title => 'Import fehlgeschlagen';
@override
String get import_generic_error_message => 'Der Import ist fehlgeschlagen.';
@override
String get export_error_title => 'Fehler';
@override
String get export_error_message => 'Datei konnte nicht exportiert werden';
@override
String get error_found => 'Fehler gefunden?';

View File

@@ -47,6 +47,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get name => 'Name';
@override
String get back => 'Back';
@override
String get home => 'Home';
@@ -58,14 +61,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get empty_text_2 =>
'Add a new round using the button in the top right corner';
'Add a new round using the button in the top right corner.';
@override
String get delete_game_title => 'Delete game?';
@override
String delete_game_message(String gameTitle) {
return 'Are you sure you want to delete the game $gameTitle? This action cannot be undone.';
return 'Are you sure you want to delete the game \"$gameTitle\"? This action cannot be undone.';
}
@override
@@ -143,6 +146,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get next_round => 'Next Round';
@override
String get statistics => 'Statistics';
@override
String get end_game => 'End Game';
@override
String get delete_game => 'Delete Game';
@override
String get new_game_same_settings => 'New Game with same Settings';
@override
String get export_game => 'Export Game';
@override
String get id_error_title => 'ID Error';
@override
String get id_error_message =>
'The game has not yet been assigned an ID. If you want to delete the game, please do so via the main menu. All newly created games have an ID.';
@override
String get end_game_title => 'End the game?';
@override
String get end_game_message =>
'Do you want to end the game? The game gets marked as finished and cannot be continued.';
@override
String get game_process => 'Spielverlauf';
@override
String get settings => 'Settings';
@@ -150,13 +185,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get cabo_penalty => 'Cabo Penalty';
@override
String get cabo_penalty_subtitle => '... for falsely calling Cabo';
String get cabo_penalty_subtitle => '... for falsely calling Cabo.';
@override
String get point_limit => 'Point Limit';
@override
String get point_limit_subtitle => '... the game ends here';
String get point_limit_subtitle => '... the game ends here.';
@override
String get reset_to_default => 'Reset to Default';
@@ -171,13 +206,37 @@ class AppLocalizationsEn extends AppLocalizations {
String get export_data => 'Export Data';
@override
String get error => 'Error';
String get import_success_title => 'Import successful';
@override
String get error_import => 'Could not import file';
String get import_success_message =>
'The game data has been successfully imported.';
@override
String get error_export => 'Could not export file';
String get import_validation_error_title => 'Validation failed';
@override
String get import_validation_error_message =>
'No Cabo-Counter game data was found. Please make sure that this is a valid Cabo-Counter export file.';
@override
String get import_format_error_title => 'Wrong format';
@override
String get import_format_error_message =>
'The file is not a valid JSON format or contains invalid data.';
@override
String get import_generic_error_title => 'Import failed';
@override
String get import_generic_error_message => 'The import has failed.';
@override
String get export_error_title => 'Export failed';
@override
String get export_error_message => 'Could not export file';
@override
String get error_found => 'Found a bug?';
@@ -196,5 +255,5 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get about_text =>
'Hey :) Thanks for being one of the first users of my first app! Ive put a lot of work into this project, and even though I (hopefully) thought of a lot, not everything will work 100% yet. So if you discover any bugs or have feedback on the design or usability, please let me know via the Testflight app or a message / email. Thank you very much!';
'Hey :) Thanks for being one of the first users of my app! Ive put a lot of work into this project, and even though I tried to think of everything, it might not work perfectly just yet. So if you discover any bugs or have feedback on the design or usability, please let me know via the TestFlight app or by sending me a message or email. Thank you very much!';
}

View File

@@ -2,7 +2,6 @@ import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/utility/globals.dart';
import 'package:cabo_counter/views/tab_view.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
@@ -12,8 +11,8 @@ Future<void> main() async {
await SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
await ConfigService.initConfig();
Globals.pointLimit = await ConfigService.getPointLimit();
Globals.caboPenalty = await ConfigService.getCaboPenalty();
ConfigService.pointLimit = await ConfigService.getPointLimit();
ConfigService.caboPenalty = await ConfigService.getCaboPenalty();
runApp(const App());
}
@@ -56,7 +55,16 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Locale('en'), // English
Locale('de'), // German
],
localeResolutionCallback: (locale, supportedLocales) {
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale?.languageCode) {
return supportedLocale;
}
}
return supportedLocales.first;
},
theme: CupertinoThemeData(
applyThemeToAll: true,
brightness: Brightness.dark,
primaryColor: CustomTheme.primaryColor,
scaffoldBackgroundColor: CustomTheme.backgroundColor,

View File

@@ -1,4 +1,3 @@
import 'package:cabo_counter/utility/globals.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// This class handles the configuration settings for the app.
@@ -7,8 +6,12 @@ import 'package:shared_preferences/shared_preferences.dart';
class ConfigService {
static const String _keyPointLimit = 'pointLimit';
static const String _keyCaboPenalty = 'caboPenalty';
static const int _defaultPointLimit = 100; // Default Value
static const int _defaultCaboPenalty = 5; // Default Value
// Actual values used in the app
static int pointLimit = 100;
static int caboPenalty = 5;
// Default values
static const int _defaultPointLimit = 100;
static const int _defaultCaboPenalty = 5;
static Future<void> initConfig() async {
final prefs = await SharedPreferences.getInstance();
@@ -48,8 +51,8 @@ class ConfigService {
/// Resets the configuration to default values.
static Future<void> resetConfig() async {
Globals.pointLimit = _defaultPointLimit;
Globals.caboPenalty = _defaultCaboPenalty;
ConfigService.pointLimit = _defaultPointLimit;
ConfigService.caboPenalty = _defaultCaboPenalty;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyPointLimit, _defaultPointLimit);
await prefs.setInt(_keyCaboPenalty, _defaultCaboPenalty);

View File

@@ -9,11 +9,19 @@ import 'package:flutter/services.dart';
import 'package:json_schema/json_schema.dart';
import 'package:path_provider/path_provider.dart';
enum ImportStatus {
success,
canceled,
validationError,
formatError,
genericError
}
class LocalStorageService {
static const String _fileName = 'game_data.json';
/// Writes the game session list to a JSON file and returns it as string.
static String getJsonFile() {
/// Writes the game session list to a JSON file and returns it as string.
static String getGameDataAsJsonFile() {
final jsonFile =
gameManager.gameList.map((session) => session.toJson()).toList();
return json.encode(jsonFile);
@@ -31,7 +39,7 @@ class LocalStorageService {
print('[local_storage_service.dart] Versuche, Daten zu speichern...');
try {
final file = await _getFilePath();
final jsonFile = getJsonFile();
final jsonFile = getGameDataAsJsonFile();
await file.writeAsString(jsonFile);
print(
'[local_storage_service.dart] Die Spieldaten wurden zwischengespeichert.');
@@ -62,7 +70,7 @@ class LocalStorageService {
return false;
}
if (!await validateJsonSchema(jsonString)) {
if (!await validateJsonSchema(jsonString, true)) {
print(
'[local_storage_service.dart] Die Datei konnte nicht validiert werden');
gameManager.gameList = [];
@@ -78,6 +86,11 @@ class LocalStorageService {
GameSession.fromJson(jsonItem as Map<String, dynamic>))
.toList();
for (GameSession session in gameManager.gameList) {
print(
'[local_storage_service.dart] Geladene Session: ${session.gameTitle} - ${session.id}');
}
print(
'[local_storage_service.dart] Die Spieldaten wurden erfolgreich geladen und verarbeitet');
return true;
@@ -89,19 +102,27 @@ class LocalStorageService {
}
}
/// Opens the file picker to save a JSON file with the current game data.
static Future<bool> exportJsonFile() async {
final jsonString = getJsonFile();
/// Opens the file picker to export game data as a JSON file.
/// This method will export the given [jsonString] as a JSON file. It opens
/// the file picker with the choosen [fileName].
static Future<bool> exportJsonData(
String jsonString,
String fileName,
) async {
try {
final bytes = Uint8List.fromList(utf8.encode(jsonString));
final result = await FileSaver.instance.saveAs(
name: 'cabo_counter_data',
final path = await FileSaver.instance.saveAs(
name: fileName,
bytes: bytes,
ext: 'json',
mimeType: MimeType.json,
);
print(
'[local_storage_service.dart] Die Spieldaten wurden exportiert. Dateipfad: $result');
if (path == null) {
print('[local_storage_service.dart]: Export abgebrochen');
} else {
print(
'[local_storage_service.dart] Die Spieldaten wurden exportiert. Dateipfad: $path');
}
return true;
} catch (e) {
print(
@@ -110,45 +131,84 @@ class LocalStorageService {
}
}
/// Opens the file picker to export all game sessions as a JSON file.
static Future<bool> exportGameData() async {
String jsonString = getGameDataAsJsonFile();
String fileName = 'cabo_counter-game_data';
return exportJsonData(jsonString, fileName);
}
/// Opens the file picker to save a single game session as a JSON file.
static Future<bool> exportSingleGameSession(GameSession session) async {
String jsonString = json.encode(session.toJson());
String fileName = 'cabo_counter-game_${session.id.substring(0, 7)}';
return exportJsonData(jsonString, fileName);
}
/// Opens the file picker to import a JSON file and loads the game data from it.
static Future<bool> importJsonFile() async {
final result = await FilePicker.platform.pickFiles(
static Future<ImportStatus> importJsonFile() async {
final path = await FilePicker.platform.pickFiles(
dialogTitle: 'Wähle eine Datei mit Spieldaten aus',
type: FileType.custom,
allowedExtensions: ['json'],
);
if (result == null) {
if (path == null) {
print(
'[local_storage_service.dart] Der Filepicker-Dialog wurde abgebrochen');
return false;
return ImportStatus.canceled;
}
try {
final jsonString = await _readFileContent(result.files.single);
final jsonString = await _readFileContent(path.files.single);
if (!await validateJsonSchema(jsonString)) {
return false;
if (await validateJsonSchema(jsonString, true)) {
// Checks if the JSON String is in the gameList format
final jsonData = json.decode(jsonString) as List<dynamic>;
List<GameSession> importedList = jsonData
.map((jsonItem) =>
GameSession.fromJson(jsonItem as Map<String, dynamic>))
.toList();
for (GameSession s in importedList) {
importSession(s);
}
} else if (await validateJsonSchema(jsonString, false)) {
// Checks if the JSON String is in the single game format
final jsonData = json.decode(jsonString) as Map<String, dynamic>;
importSession(GameSession.fromJson(jsonData));
} else {
return ImportStatus.validationError;
}
final jsonData = json.decode(jsonString) as List<dynamic>;
gameManager.gameList = jsonData
.map((jsonItem) =>
GameSession.fromJson(jsonItem as Map<String, dynamic>))
.toList();
print(
'[local_storage_service.dart] Die Datei wurde erfolgreich Importiertn');
return true;
'[local_storage_service.dart] Die Datei wurde erfolgreich Importiert');
await saveGameSessions();
return ImportStatus.success;
} on FormatException catch (e) {
print(
'[local_storage_service.dart] Ungültiges JSON-Format. Exception: $e');
return false;
return ImportStatus.formatError;
} on Exception catch (e) {
print(
'[local_storage_service.dart] Fehler beim Dateizugriff. Exception: $e');
return false;
return ImportStatus.genericError;
}
}
/// Imports a single game session into the gameList.
static Future<void> importSession(GameSession session) async {
if (gameManager.gameExistsInGameList(session.id)) {
print(
'[local_storage_service.dart] Die Session mit der ID ${session.id} existiert bereits. Sie wird überschrieben.');
gameManager.removeGameSessionById(session.id);
}
gameManager.addGameSession(session);
print(
'[local_storage_service.dart] Die Session mit der ID ${session.id} wurde erfolgreich importiert.');
}
/// Helper method to read file content from either bytes or path
static Future<String> _readFileContent(PlatformFile file) async {
if (file.bytes != null) return utf8.decode(file.bytes!);
@@ -158,15 +218,28 @@ class LocalStorageService {
}
/// Validates the JSON data against the schema.
static Future<bool> validateJsonSchema(String jsonString) async {
/// This method checks if the provided [jsonString] is valid against the
/// JSON schema. It takes a boolean [isGameList] to determine
/// which schema to use (game list or single game).
static Future<bool> validateJsonSchema(
String jsonString, bool isGameList) async {
final String schemaString;
if (isGameList) {
schemaString =
await rootBundle.loadString('assets/game_list-schema.json');
} else {
schemaString = await rootBundle.loadString('assets/game-schema.json');
}
try {
final schemaString = await rootBundle.loadString('assets/schema.json');
final schema = JsonSchema.create(json.decode(schemaString));
final jsonData = json.decode(jsonString);
final result = schema.validate(jsonData);
if (result.isValid) {
print('[local_storage_service.dart] JSON ist erfolgreich validiert.');
print(
'[local_storage_service.dart] JSON ist erfolgreich validiert. Typ: ${isGameList ? 'Game List' : 'Single Game'}');
return true;
}
print(

View File

@@ -1,5 +1,3 @@
class Globals {
static int pointLimit = 100;
static int caboPenalty = 5;
static String appDevPhase = 'Beta';
}

View File

@@ -1,8 +1,13 @@
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/views/create_game_view.dart';
import 'package:cabo_counter/views/graph_view.dart';
import 'package:cabo_counter/views/round_view.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class ActiveGameView extends StatefulWidget {
final GameSession gameSession;
@@ -14,15 +19,23 @@ class ActiveGameView extends StatefulWidget {
}
class _ActiveGameViewState extends State<ActiveGameView> {
late final GameSession gameSession;
@override
void initState() {
super.initState();
gameSession = widget.gameSession;
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.gameSession,
listenable: gameSession,
builder: (context, _) {
List<int> sortedPlayerIndices = _getSortedPlayerIndices();
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(widget.gameSession.gameTitle),
middle: Text(gameSession.gameTitle),
),
child: SafeArea(
child: SingleChildScrollView(
@@ -39,7 +52,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.gameSession.players.length,
itemCount: gameSession.players.length,
itemBuilder: (BuildContext context, int index) {
int playerIndex = sortedPlayerIndices[index];
return CupertinoListTile(
@@ -48,7 +61,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
_getPlacementPrefix(index),
const SizedBox(width: 5),
Text(
widget.gameSession.players[playerIndex],
gameSession.players[playerIndex],
style: const TextStyle(
fontWeight: FontWeight.bold),
),
@@ -57,8 +70,7 @@ class _ActiveGameViewState extends State<ActiveGameView> {
trailing: Row(
children: [
const SizedBox(width: 5),
Text(
'${widget.gameSession.playerScores[playerIndex]} '
Text('${gameSession.playerScores[playerIndex]} '
'${AppLocalizations.of(context).points}')
],
),
@@ -75,38 +87,133 @@ class _ActiveGameViewState extends State<ActiveGameView> {
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.gameSession.roundNumber,
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 !=
widget.gameSession.roundNumber ||
widget.gameSession.isGameFinished ==
true
? (const Text('\u{2705}',
style: TextStyle(fontSize: 22)))
: const Text('\u{23F3}',
style: TextStyle(fontSize: 22)),
trailing:
index + 1 != gameSession.roundNumber ||
gameSession.isGameFinished == true
? (const Text('\u{2705}',
style: TextStyle(fontSize: 22)))
: const Text('\u{23F3}',
style: TextStyle(fontSize: 22)),
onTap: () async {
// ignore: unused_local_variable
final val = await Navigator.of(context,
rootNavigator: true)
.push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
gameSession: widget.gameSession,
roundNumber: index + 1),
),
);
_openRoundView(index + 1);
},
));
},
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 0, 0),
child: Text(
AppLocalizations.of(context).game,
style: CustomTheme.rowTitle,
),
),
Column(
children: [
CupertinoListTile(
title: Text(
AppLocalizations.of(context).statistics,
),
backgroundColorActivated:
CustomTheme.backgroundColor,
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => GraphView(
gameSession: gameSession,
)))),
if (!gameSession.isPointsLimitEnabled)
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();
}
}),
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:
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),
),
],
),
);
}
}),
],
)
],
),
),
@@ -114,15 +221,48 @@ class _ActiveGameViewState extends State<ActiveGameView> {
});
}
/// Shows a dialog to confirm ending the game.
/// If the user confirms, it calls the `endGame` method on the game manager
void _showEndGameDialog() {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).end_game_title),
content: Text(AppLocalizations.of(context).end_game_message),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).end_game,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
),
onPressed: () {
setState(() {
gameManager.endGame(gameSession.id);
});
Navigator.pop(context);
},
),
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).cancel),
onPressed: () => Navigator.pop(context),
),
],
);
},
);
}
/// Returns a list of player indices sorted by their scores in
/// ascending order.
List<int> _getSortedPlayerIndices() {
List<int> playerIndices =
List<int>.generate(widget.gameSession.players.length, (index) => index);
List<int>.generate(gameSession.players.length, (index) => index);
// Sort the indices based on the summed points
playerIndices.sort((a, b) {
int scoreA = widget.gameSession.playerScores[a];
int scoreB = widget.gameSession.playerScores[b];
int scoreA = gameSession.playerScores[a];
int scoreB = gameSession.playerScores[b];
return scoreA.compareTo(scoreB);
});
return playerIndices;
@@ -155,4 +295,82 @@ class _ActiveGameViewState extends State<ActiveGameView> {
);
}
}
Future<bool> _showDeleteGameDialog() async {
return await showCupertinoDialog<bool>(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).delete_game_title),
content: Text(
AppLocalizations.of(context)
.delete_game_message(gameSession.gameTitle),
),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).cancel),
onPressed: () => Navigator.pop(context, false),
),
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
),
onPressed: () {
Navigator.pop(context, true);
},
),
],
);
},
) ??
false;
}
Future<void> _removeGameSession(GameSession gameSession) async {
if (gameManager.gameExistsInGameList(gameSession.id)) {
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
gameManager.removeGameSessionById(gameSession.id);
});
} else {
showCupertinoDialog(
context: context,
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).id_error_title),
content: Text(AppLocalizations.of(context).id_error_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
}
/// Recursively opens the RoundView for the specified round number.
/// It starts with the given [roundNumber] and continues to open the next round
/// until the user navigates back or the round number is invalid.
void _openRoundView(int roundNumber) async {
final val = await Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
fullscreenDialog: true,
builder: (context) => RoundView(
gameSession: gameSession,
roundNumber: roundNumber,
),
),
);
if (val != null && val >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 600));
_openRoundView(val);
});
}
}
}

View File

@@ -1,21 +1,38 @@
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/utility/globals.dart';
import 'package:cabo_counter/views/active_game_view.dart';
import 'package:cabo_counter/views/mode_selection_view.dart';
import 'package:flutter/cupertino.dart';
class CreateGame extends StatefulWidget {
const CreateGame({super.key});
enum CreateStatus {
noGameTitle,
noModeSelected,
minPlayers,
maxPlayers,
noPlayerName,
}
class CreateGameView extends StatefulWidget {
final String? gameTitle;
final bool? isPointsLimitEnabled;
final List<String>? players;
const CreateGameView({
super.key,
this.gameTitle,
this.isPointsLimitEnabled,
this.players,
});
@override
// ignore: library_private_types_in_public_api
_CreateGameState createState() => _CreateGameState();
_CreateGameViewState createState() => _CreateGameViewState();
}
class _CreateGameState extends State<CreateGame> {
class _CreateGameViewState extends State<CreateGameView> {
final List<TextEditingController> _playerNameTextControllers = [
TextEditingController()
];
@@ -25,8 +42,23 @@ class _CreateGameState extends State<CreateGame> {
/// Maximum number of players allowed in the game.
final int maxPlayers = 5;
/// Variable to store the selected game mode.
bool? selectedMode;
/// Variable to store whether the points limit feature is enabled.
bool? _isPointsLimitEnabled;
@override
void initState() {
super.initState();
_isPointsLimitEnabled = widget.isPointsLimitEnabled;
_gameTitleTextController.text = widget.gameTitle ?? '';
if (widget.players != null) {
_playerNameTextControllers.clear();
for (var player in widget.players!) {
_playerNameTextControllers.add(TextEditingController(text: player));
}
}
}
@override
Widget build(BuildContext context) {
@@ -69,10 +101,10 @@ class _CreateGameState extends State<CreateGame> {
suffix: Row(
children: [
Text(
selectedMode == null
_isPointsLimitEnabled == null
? AppLocalizations.of(context).select_mode
: (selectedMode!
? '${Globals.pointLimit} ${AppLocalizations.of(context).points}'
: (_isPointsLimitEnabled!
? '${ConfigService.pointLimit} ${AppLocalizations.of(context).points}'
: AppLocalizations.of(context).unlimited),
),
const SizedBox(width: 3),
@@ -80,18 +112,18 @@ class _CreateGameState extends State<CreateGame> {
],
),
onTap: () async {
final selected = await Navigator.push(
final selectedMode = await Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => ModeSelectionMenu(
pointLimit: Globals.pointLimit,
pointLimit: ConfigService.pointLimit,
),
),
);
if (selected != null) {
if (selectedMode != null) {
setState(() {
selectedMode = selected;
_isPointsLimitEnabled = selectedMode;
});
}
},
@@ -139,22 +171,7 @@ class _CreateGameState extends State<CreateGame> {
.add(TextEditingController());
});
} else {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context)
.max_players_title),
content: Text(AppLocalizations.of(context)
.max_players_message),
actions: [
CupertinoDialogAction(
child:
Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
showFeedbackDialog(CreateStatus.maxPlayers);
}
},
),
@@ -183,6 +200,7 @@ class _CreateGameState extends State<CreateGame> {
Expanded(
child: CupertinoTextField(
controller: _playerNameTextControllers[index],
maxLength: 12,
placeholder:
'${AppLocalizations.of(context).player} ${index + 1}',
padding: const EdgeInsets.all(12),
@@ -212,73 +230,19 @@ class _CreateGameState extends State<CreateGame> {
),
onPressed: () async {
if (_gameTitleTextController.text == '') {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(
AppLocalizations.of(context).no_gameTitle_title),
content: Text(
AppLocalizations.of(context).no_gameTitle_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
showFeedbackDialog(CreateStatus.noGameTitle);
return;
}
if (selectedMode == null) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).no_mode_title),
content:
Text(AppLocalizations.of(context).no_mode_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
if (_isPointsLimitEnabled == null) {
showFeedbackDialog(CreateStatus.noModeSelected);
return;
}
if (_playerNameTextControllers.length < 2) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(
AppLocalizations.of(context).min_players_title),
content: Text(
AppLocalizations.of(context).min_players_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
showFeedbackDialog(CreateStatus.minPlayers);
return;
}
if (!everyPlayerHasAName()) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(AppLocalizations.of(context).no_name_title),
content:
Text(AppLocalizations.of(context).no_name_message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
),
);
showFeedbackDialog(CreateStatus.noPlayerName);
return;
}
@@ -290,17 +254,18 @@ class _CreateGameState extends State<CreateGame> {
createdAt: DateTime.now(),
gameTitle: _gameTitleTextController.text,
players: players,
pointLimit: Globals.pointLimit,
caboPenalty: Globals.caboPenalty,
isPointsLimitEnabled: selectedMode!,
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: gameManager.gameList[index])));
builder: (context) =>
ActiveGameView(gameSession: session)));
}
},
),
@@ -309,6 +274,60 @@ class _CreateGameState extends State<CreateGame> {
))));
}
/// Displays a feedback dialog based on the [CreateStatus].
void showFeedbackDialog(CreateStatus status) {
final (title, message) = _getDialogContent(status);
showCupertinoDialog(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
/// Returns the title and message for the dialog based on the [CreateStatus].
(String, String) _getDialogContent(CreateStatus status) {
switch (status) {
case CreateStatus.noGameTitle:
return (
AppLocalizations.of(context).no_gameTitle_title,
AppLocalizations.of(context).no_gameTitle_message
);
case CreateStatus.noModeSelected:
return (
AppLocalizations.of(context).no_mode_title,
AppLocalizations.of(context).no_mode_message
);
case CreateStatus.minPlayers:
return (
AppLocalizations.of(context).min_players_title,
AppLocalizations.of(context).min_players_message
);
case CreateStatus.maxPlayers:
return (
AppLocalizations.of(context).max_players_title,
AppLocalizations.of(context).max_players_message
);
case CreateStatus.noPlayerName:
return (
AppLocalizations.of(context).no_name_title,
AppLocalizations.of(context).no_name_message
);
}
}
/// 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 == '') {
@@ -320,9 +339,11 @@ class _CreateGameState extends State<CreateGame> {
@override
void dispose() {
_gameTitleTextController.dispose();
for (var controller in _playerNameTextControllers) {
controller.dispose();
}
super.dispose();
}
}

84
lib/views/graph_view.dart Normal file
View File

@@ -0,0 +1,84 @@
import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
class GraphView extends StatefulWidget {
final GameSession gameSession;
const GraphView({super.key, required this.gameSession});
@override
State<GraphView> createState() => _GraphViewState();
}
class _GraphViewState extends State<GraphView> {
/// List of colors for the graph lines.
List<Color> lineColors = [
Colors.red,
Colors.blue,
Colors.orange.shade400,
Colors.purple,
Colors.green,
];
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(AppLocalizations.of(context).game_process),
previousPageTitle: AppLocalizations.of(context).back,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 100, 0, 0),
child: SfCartesianChart(
legend:
const Legend(isVisible: true, position: LegendPosition.bottom),
primaryXAxis: const NumericAxis(),
primaryYAxis: const NumericAxis(),
series: getCumulativeScores(),
),
),
);
}
/// Returns a list of LineSeries representing the cumulative scores of each player.
/// Each series contains data points for each round, showing the cumulative score up to that round.
/// The x-axis represents the round number, and the y-axis represents the cumulative score.
List<LineSeries<(int, int), int>> getCumulativeScores() {
final rounds = widget.gameSession.roundList;
final playerCount = widget.gameSession.players.length;
final playerNames = widget.gameSession.players;
List<List<int>> cumulativeScores = List.generate(playerCount, (_) => []);
List<int> runningTotals = List.filled(playerCount, 0);
for (var round in rounds) {
for (int i = 0; i < playerCount; i++) {
runningTotals[i] += round.scores[i];
cumulativeScores[i].add(runningTotals[i]);
}
}
/// Create a list of LineSeries for each player
/// Each series contains data points for each round
return List.generate(playerCount, (i) {
final data = List.generate(
cumulativeScores[i].length,
(j) => (j + 1, cumulativeScores[i][j]), // (round, score)
);
/// Create a LineSeries for the player
/// The xValueMapper maps the round number, and the yValueMapper maps the cumulative score.
return LineSeries<(int, int), int>(
name: playerNames[i],
dataSource: data,
xValueMapper: (record, _) => record.$1, // Runde
yValueMapper: (record, _) => record.$2, // Punktestand
markerSettings: const MarkerSettings(isVisible: true),
color: lineColors[i],
);
});
}
}

View File

@@ -1,8 +1,8 @@
import 'package:cabo_counter/data/game_manager.dart';
import 'package:cabo_counter/l10n/app_localizations.dart';
import 'package:cabo_counter/services/config_service.dart';
import 'package:cabo_counter/services/local_storage_service.dart';
import 'package:cabo_counter/utility/custom_theme.dart';
import 'package:cabo_counter/utility/globals.dart';
import 'package:cabo_counter/views/active_game_view.dart';
import 'package:cabo_counter/views/create_game_view.dart';
import 'package:cabo_counter/views/settings_view.dart';
@@ -50,7 +50,9 @@ class _MainMenuViewState extends State<MainMenuView> {
CupertinoPageRoute(
builder: (context) => const SettingsView(),
),
);
).then((_) {
setState(() {});
});
},
icon: const Icon(CupertinoIcons.settings, size: 30)),
middle: const Text('Cabo Counter'),
@@ -59,7 +61,7 @@ class _MainMenuViewState extends State<MainMenuView> {
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) => const CreateGame(),
builder: (context) => const CreateGameView(),
),
)
},
@@ -77,7 +79,13 @@ class _MainMenuViewState extends State<MainMenuView> {
const SizedBox(height: 30), // Abstand von oben
Center(
child: GestureDetector(
onTap: () => setState(() {}),
onTap: () => Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
const CreateGameView(),
),
),
child: Icon(
CupertinoIcons.plus,
size: 60,
@@ -85,12 +93,13 @@ class _MainMenuViewState extends State<MainMenuView> {
),
)),
const SizedBox(height: 10), // Abstand von oben
const Padding(
padding: EdgeInsets.symmetric(horizontal: 70),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 70),
child: Text(
'Ganz schön leer hier...\nFüge über den Button oben rechts eine neue Runde hinzu.',
'${AppLocalizations.of(context).empty_text_1}\n${AppLocalizations.of(context).empty_text_2}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
style: const TextStyle(fontSize: 16),
),
),
],
@@ -106,15 +115,15 @@ class _MainMenuViewState extends State<MainMenuView> {
key: Key(session.gameTitle),
background: Container(
color: CupertinoColors.destructiveRed,
alignment: Alignment.centerLeft,
alignment: Alignment.centerRight,
padding:
const EdgeInsets.only(left: 20.0),
const EdgeInsets.only(right: 20.0),
child: const Icon(
CupertinoIcons.delete,
color: CupertinoColors.white,
),
),
direction: DismissDirection.startToEnd,
direction: DismissDirection.endToStart,
confirmDismiss: (direction) async {
final String gameTitle = gameManager
.gameList[index].gameTitle;
@@ -122,7 +131,8 @@ class _MainMenuViewState extends State<MainMenuView> {
gameTitle);
},
onDismissed: (direction) {
gameManager.removeGameSession(index);
gameManager
.removeGameSessionByIndex(index);
},
dismissThresholds: const {
DismissDirection.startToEnd: 0.6
@@ -159,18 +169,19 @@ class _MainMenuViewState extends State<MainMenuView> {
CupertinoIcons.person_2_fill),
],
),
onTap: () async {
//ignore: unused_local_variable
final val = await Navigator.push(
onTap: () {
final session =
gameManager.gameList[index];
Navigator.push(
context,
CupertinoPageRoute(
builder: (context) =>
ActiveGameView(
gameSession: gameManager
.gameList[index]),
gameSession: session),
),
);
setState(() {});
).then((_) {
setState(() {});
});
},
),
),
@@ -188,7 +199,7 @@ class _MainMenuViewState extends State<MainMenuView> {
/// If [pointLimit] is true, it returns '101 Punkte', otherwise it returns 'Unbegrenzt'.
String _translateGameMode(bool pointLimit) {
if (pointLimit) {
return '${Globals.pointLimit} ${AppLocalizations.of(context).points}';
return '${ConfigService.pointLimit} ${AppLocalizations.of(context).points}';
}
return AppLocalizations.of(context).unlimited;
}
@@ -215,7 +226,11 @@ class _MainMenuViewState extends State<MainMenuView> {
onPressed: () {
Navigator.pop(context, true);
},
child: Text(AppLocalizations.of(context).delete),
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red),
),
),
],
);

View File

@@ -67,6 +67,7 @@ class _RoundViewState extends State<RoundView> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final maxLength = widget.gameSession.getMaxLengthOfPlayerNames();
return CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
@@ -75,10 +76,8 @@ class _RoundViewState extends State<RoundView> {
middle: Text(AppLocalizations.of(context).results),
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => {
LocalStorageService.saveGameSessions(),
Navigator.pop(context, widget.gameSession)
},
onPressed: () =>
{LocalStorageService.saveGameSessions(), Navigator.pop(context)},
child: Text(AppLocalizations.of(context).cancel),
),
),
@@ -122,28 +121,21 @@ class _RoundViewState extends State<RoundView> {
index,
Padding(
padding: EdgeInsets.symmetric(
horizontal: widget.gameSession
.getLengthOfPlayerNames() >
20
? (widget.gameSession
.getLengthOfPlayerNames() >
32
? 5
: 10)
: 15,
horizontal: 4 +
_getSegmentedControlPadding(maxLength),
vertical: 6,
),
child: Text(
name,
textAlign: TextAlign.center,
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: widget.gameSession
.getLengthOfPlayerNames() >
28
? 14
: 18,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
name,
textAlign: TextAlign.center,
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: _getSegmentedControlFontSize(
maxLength),
),
),
),
),
@@ -191,7 +183,13 @@ class _RoundViewState extends State<RoundView> {
borderRadius: BorderRadius.circular(12),
child: CupertinoListTile(
backgroundColor: CupertinoColors.secondaryLabel,
title: Row(children: [Text(name)]),
title: Row(children: [
Expanded(
child: Text(
name,
overflow: TextOverflow.ellipsis,
))
]),
subtitle: Text(
'${widget.gameSession.playerScores[index]}'
' ${AppLocalizations.of(context).points}'),
@@ -290,7 +288,7 @@ class _RoundViewState extends State<RoundView> {
? () {
_finishRound();
LocalStorageService.saveGameSessions();
Navigator.pop(context, widget.gameSession);
Navigator.pop(context);
}
: null,
child: Text(AppLocalizations.of(context).done),
@@ -301,17 +299,10 @@ class _RoundViewState extends State<RoundView> {
_finishRound();
LocalStorageService.saveGameSessions();
if (widget.gameSession.isGameFinished == true) {
Navigator.pop(context, widget.gameSession);
Navigator.pop(context);
} else {
Navigator.of(context, rootNavigator: true)
.pushReplacement(
CupertinoPageRoute(
builder: (context) => RoundView(
gameSession: widget.gameSession,
roundNumber: widget.roundNumber + 1,
),
),
);
Navigator.pop(
context, widget.roundNumber + 1);
}
}
: null,
@@ -395,6 +386,32 @@ class _RoundViewState extends State<RoundView> {
}
}
double _getSegmentedControlFontSize(int maxLength) {
if (maxLength > 8) {
// 9 - 12 characters
return 9.0;
} else if (maxLength > 4) {
// 5 - 8 characters
return 15.0;
} else {
// 0 - 4 characters
return 18.0;
}
}
double _getSegmentedControlPadding(int maxLength) {
if (maxLength > 8) {
// 9 - 12 characters
return 0.0;
} else if (maxLength > 4) {
// 5 - 8 characters
return 5.0;
} else {
// 0 - 4 characters
return 8.0;
}
}
@override
void dispose() {
for (final controller in _scoreControllerList) {

View File

@@ -52,14 +52,14 @@ class _SettingsViewState extends State<SettingsView> {
AppLocalizations.of(context).cabo_penalty_subtitle),
trailing: Stepper(
key: _stepperKey1,
initialValue: Globals.caboPenalty,
initialValue: ConfigService.caboPenalty,
minValue: 0,
maxValue: 50,
step: 1,
onChanged: (newCaboPenalty) {
setState(() {
ConfigService.setCaboPenalty(newCaboPenalty);
Globals.caboPenalty = newCaboPenalty;
ConfigService.caboPenalty = newCaboPenalty;
});
},
),
@@ -73,14 +73,14 @@ class _SettingsViewState extends State<SettingsView> {
Text(AppLocalizations.of(context).point_limit_subtitle),
trailing: Stepper(
key: _stepperKey2,
initialValue: Globals.pointLimit,
initialValue: ConfigService.pointLimit,
minValue: 30,
maxValue: 1000,
step: 10,
onChanged: (newPointLimit) {
setState(() {
ConfigService.setPointLimit(newPointLimit);
Globals.pointLimit = newPointLimit;
ConfigService.pointLimit = newPointLimit;
});
},
),
@@ -125,27 +125,7 @@ class _SettingsViewState extends State<SettingsView> {
onPressed: () async {
final success =
await LocalStorageService.importJsonFile();
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(
AppLocalizations.of(context)
.error),
content: Text(
AppLocalizations.of(context)
.error_import),
actions: [
CupertinoDialogAction(
child: Text(
AppLocalizations.of(context)
.ok),
onPressed: () =>
Navigator.pop(context),
),
],
));
}
showFeedbackDialog(success);
}),
const SizedBox(
width: 20,
@@ -160,15 +140,15 @@ class _SettingsViewState extends State<SettingsView> {
),
onPressed: () async {
final success =
await LocalStorageService.exportJsonFile();
await LocalStorageService.exportGameData();
if (!success && context.mounted) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title:
Text(AppLocalizations.of(context).error),
title: Text(AppLocalizations.of(context)
.export_error_title),
content: Text(AppLocalizations.of(context)
.error_export),
.export_error_message),
actions: [
CupertinoDialogAction(
child:
@@ -236,4 +216,52 @@ class _SettingsViewState extends State<SettingsView> {
Future<PackageInfo> _getPackageInfo() async {
return await PackageInfo.fromPlatform();
}
void showFeedbackDialog(ImportStatus status) {
if (status == ImportStatus.canceled) return;
final (title, message) = _getDialogContent(status);
showCupertinoDialog(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(title),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(AppLocalizations.of(context).ok),
onPressed: () => Navigator.pop(context),
),
],
);
});
}
(String, String) _getDialogContent(ImportStatus status) {
switch (status) {
case ImportStatus.success:
return (
AppLocalizations.of(context).import_success_title,
AppLocalizations.of(context).import_success_message
);
case ImportStatus.validationError:
return (
AppLocalizations.of(context).import_validation_error_title,
AppLocalizations.of(context).import_validation_error_message
);
case ImportStatus.formatError:
return (
AppLocalizations.of(context).import_format_error_title,
AppLocalizations.of(context).import_format_error_message
);
case ImportStatus.genericError:
return (
AppLocalizations.of(context).import_generic_error_title,
AppLocalizations.of(context).import_generic_error_message
);
case ImportStatus.canceled:
return ('', '');
}
}
}

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo"
publish_to: 'none'
version: 0.3.0+232
version: 0.3.9+331
environment:
sdk: ^3.5.4
@@ -25,6 +25,8 @@ dependencies:
flutter_localizations:
sdk: flutter
intl: any
syncfusion_flutter_charts: ^30.1.37
uuid: ^4.5.1
dev_dependencies:
flutter_test:
@@ -37,4 +39,5 @@ flutter:
uses-material-design: false
assets:
- assets/cabo_counter-logo_rounded.png
- assets/schema.json
- assets/game_list-schema.json
- assets/game-schema.json

View File

@@ -61,9 +61,8 @@ void main() {
});
group('Helper Functions', () {
test('getLengthOfPlayerNames', () {
expect(session.getLengthOfPlayerNames(),
equals(15)); // Alice(5) + Bob(3) + Charlie(7)
test('getMaxLengthOfPlayerNames', () {
expect(session.getMaxLengthOfPlayerNames(), equals(7)); // Charlie (7)
});
test('increaseRound', () {